返回

SwiftUI 叠加 Sheet 终极方案:解决多模态视图难题

IOS

SwiftUI 叠加 Sheet 的解决方案

问题

在 SwiftUI 应用开发中,sheet 修饰符用于呈现模态视图,用户交互主要围绕此。 但当应用需要自动、非用户触发式地弹出 sheet 时,特别是存在多个潜在的自动 sheet,sheet 修饰符的局限性就显现出来。标准的 sheet 修饰符,无法简单叠加展示多个 sheet;一旦存在父 sheet,嵌套的子 sheet 将不会如预期展现,这直接导致了嵌套 sheet 的困境,开发者需要额外方式管理。比如下方代码,sheet2Open 所绑定的sheet 是不可能展示的,因为父view已经被 sheet1Open 所绑定 sheet 给覆盖了。

var body: some View {
    VStack {
        Image(systemName: "globe")
            .imageScale(.large)
            .foregroundStyle(.tint)
        Text("Hello, world!")
    }
    .onTapGesture {
        sheet1Open.toggle()
    }
.sheet(isPresented: $sheet1Open) {
  Sheet1Veiw() 
        .onTapGesture {
            sheet2Open.toggle()
        }
}
.sheet(isPresented: $sheet2Open) {
  Sheet2Veiw() 
}

常见原因

SwiftUI 的 sheet 修饰符默认情况下只能一次呈现一个视图。当一个 sheet 已经呈现,再次尝试呈现另一个 sheet,由于视图层级关系以及 Swift UI sheet present 的机制导致后面的 sheet 将无法按预期展示。直接嵌套 .sheet 可能会造成某些 sheet 无法被呈现,而且会导致整个 app 的结构变得难以维护和理解。

解决方案一:环境对象(Environment Object)

使用一个集中管理 sheet 展示状态的 environment object,它保存待展示 sheet 的数据以及显示控制。 当 environment object 的状态改变,绑定它的 sheet 就可以动态地展现或隐藏,从而模拟多个叠加的 sheet 展示,实现自动 sheet 的管理。

  1. 定义 SheetManager 类:该类实现 ObservableObject 协议,以便 SwiftUI 在其更改时更新视图。 包含一个枚举类型用于定义sheet类型,以及 @Published 属性,当sheet 类型更改时通知观察者并展示响应视图。

    import SwiftUI
    
    enum SheetType: Identifiable {
        case sheet1
        case sheet2
        case none
      var id: Self {self}
    }
    
    class SheetManager: ObservableObject {
        @Published var currentSheet: SheetType = .none
    
        func showSheet(_ sheet: SheetType) {
           self.currentSheet = sheet
         }
         func hideSheet(){
            self.currentSheet = .none
        }
    }
    
  2. 在最外层父级视图上初始化这个 SheetManager,并且把它插入环境中,方便所有子View 可以通过 EnvironmentObject 的方法来读取它。

@main
struct YourApp: App {
    @StateObject var sheetManager = SheetManager()
    var body: some Scene {
        WindowGroup {
            ContentView().environmentObject(sheetManager)
        }
    }
}

  1. 在你需要呈现 sheet 的视图中,读取 SheetManager,并通过其发布的 currentSheet 属性绑定 .sheet,使用 switch语句来管理sheet的类型以及展示的内容:
struct ContentView: View {
    @EnvironmentObject var sheetManager: SheetManager

    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .onAppear {
          // for example, pop first sheet
             DispatchQueue.main.asyncAfter(deadline: .now()+2){
                   sheetManager.showSheet(.sheet1)
              }
         }
       .sheet(item: $sheetManager.currentSheet) { currentSheet in
               switch currentSheet {
                     case .sheet1 :
                           Sheet1Veiw()
                      case .sheet2:
                             Sheet2Veiw()
                    case .none:
                        EmptyView()
                }
          }

    }
}


struct Sheet1Veiw:View {
     @EnvironmentObject var sheetManager: SheetManager

      var body: some View{
           Text("Sheet1 view")
                .onTapGesture {
                  sheetManager.showSheet(.sheet2)
               }
      }
}
struct Sheet2Veiw:View {

    var body: some View{
          Text("Sheet2 view")
       }
}

优势 : 管理集中,更容易跟踪和调整 sheet 的行为。适用于复杂的多个 sheet 展示场景,可复用性高, 代码更加整洁易读,逻辑结构清晰。
安全提示 : Environment Object 的实例应该谨慎管理,避免过早释放或重新初始化,不然可能会造成数据异常或UI的渲染错误。建议使用 @StateObject 修饰根视图所引用的环境对象,并且注意及时地取消不再需要的订阅或更改监听。

解决方案二:自定义 Modal View

在SwiftUI中,可以通过ZStack 和一个 overlay view 来自定义模态视图的效果。通过在 ZStack 最上面叠加 View 来模仿 Sheet 的呈现方式。这个方法提供了更大的灵活性,允许你在视图层级之上完全控制 sheet 的行为,如出现、消失的动画,并且可以直接控制 sheet 的展示,不再受 .sheet 修饰符的限制。

  1. 创建一个新的 ModalView ,该 view 将会充当一个自定义的 sheet 。 它会接收一些绑定数据 (例如 $isPresent) 用于显示/隐藏,同时接收具体展示的内容作为 @ViewBuilder closure。

    import SwiftUI
    
    struct ModalView<Content: View>: View {
        @Binding var isPresented: Bool
        var content: () -> Content
    
        var body: some View {
            ZStack {
                if isPresented {
                     Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)
                                    .transition(.opacity)
    
                       content()
                            .transition(.move(edge: .bottom))
    
                 }
             }
                .animation(.spring(), value: isPresented)
    
       }
    
    }
    
  2. 在父级 View 中管理需要显示的 Model ,使用自定义 ModalView 展示不同内容的模态框。

struct ContentView: View {
  @State  private var isSheet1Present = false
    @State private var isSheet2Present = false


    var body: some View {
         ZStack {
                 VStack {
                Image(systemName: "globe")
                    .imageScale(.large)
                    .foregroundStyle(.tint)
                Text("Hello, world!")
                     .onTapGesture{
                        isSheet1Present.toggle()
                   }

              }


               ModalView(isPresented: $isSheet1Present) {
                      Sheet1Veiw2(isPresented:$isSheet1Present,isSheet2Present:$isSheet2Present )

                  }
                 ModalView(isPresented: $isSheet2Present){
                         Sheet2Veiw2(isSheet2Present:$isSheet2Present)
                   }
          }
             .onAppear {
           // For example, auto present sheet after 2s.
            DispatchQueue.main.asyncAfter(deadline: .now() + 2){
              isSheet1Present.toggle()
               }
              }

   }
}


struct Sheet1Veiw2:View {
     @Binding var isPresented : Bool
     @Binding var isSheet2Present :Bool
      var body: some View{
           Text("Sheet1 view")
             .onTapGesture {
                     isPresented = false
                       isSheet2Present = true
               }

     }
}
struct Sheet2Veiw2:View {
    @Binding var isSheet2Present :Bool
    var body: some View{
          Text("Sheet2 view")
             .onTapGesture {
                  isSheet2Present = false
                  }

       }
}

优势 : 提供对 Sheet 展现和行为完全的掌控,可以定制更复杂的动画或转场。可以自由地嵌套 sheet,不受原生 .sheet 的限制。
安全提示 : 记得为自定义 modal 加入合适的透明遮罩背景,以及提供方便的取消关闭方式,以保证最佳的用户体验。 在自定义 transition 的过程中,要注意性能以及流畅性,使用缓存视图,可以避免过渡过程的视图重复加载问题。

通过上述方案,开发者可以解决 SwiftUI 中叠加 Sheet 的难题。 选择何种方案,应该取决于应用复杂度,对定制的需求程度,以及性能考量。 使用合适的技巧,有助于建立健壮且高效的应用。