返回

SwiftUI Sheet Binding 失效问题:原因分析与三种解决方案

IOS

SwiftUI 中 .sheet 引起的 Binding 失效问题:探究与解决

遇到一个 SwiftUI 的奇怪问题:通过 .sheet 呈现视图时,Binding 竟然会失效!具体表现是,自定义的 RouterRouterView 用于管理多个 .sheet 的显示,这本身没问题。但在.sheet展示的内容里使用 Binding ,问题就来了。

问题根源:视图的生命周期和 Binding

要弄明白这个问题,得先看看 SwiftUI 里视图的生命周期以及 Binding 的工作方式。

Binding 是一种双向数据绑定机制,连接了视图和数据源。数据源的改动会更新视图,反过来视图上的操作也会更新数据源。听起来简单,但 .sheet 的出现让情况变得复杂。

关键点在于,.sheet 呈现的视图,它是有自己独立生命周期的。当使用自定义的 Router 来控制 .sheet 显示时,虽然数据在 ViewModel 中更新了,但.sheet内的视图,并不能实时响应。 原因很可能是.sheet的内容在某些时候会被重新创建,导致Binding丢失,或不在一个相同的View层次结构中,无法刷新。

解决方案:保持视图一致性与数据同步

要解决这个问题,核心在于确保 .sheet 内的视图与数据源之间的 Binding 连接始终有效,并确保视图能正确响应数据变化。下面列出几个解决方案:

方案一: 使用 Identifiable 协议和 .sheet(item:)

这种方式,能够更好地管理 sheet 的状态和生命周期。 通过 item 参数,可以保证每次显示的是新的 sheet,视图与数据同步更新。

  1. 原理:
    sheet(item:content:) 接收一个遵循 Identifiable 协议的可选项。当这个可选项不为 nil 时,sheet 显示;当它变为 nil 时,sheet 关闭。通过绑定一个 Identifiable 的对象,可以保证每次显示 sheet 时都传入一个新的 item,强制 SwiftUI 重新创建 sheet 内容,从而解决 Binding 失效问题。

  2. 代码示例:

// 定义一个遵循 Identifiable 协议的结构体,作为 sheet 的内容载体
struct SheetItem: Identifiable {
    let id = UUID() // 必须有一个唯一的 id
    let view: AnyView
}

class Router: ObservableObject {
    @Published var sheetItem: SheetItem? = nil

    func present<Content: View>(content: Content) {
        self.sheetItem = SheetItem(view: AnyView(content))
    }
}

struct RouterView<Content: View>: View {
    @ObservedObject var router: Router

    private var content: () -> Content

    init (_ router: Router, @ViewBuilder content: @escaping () -> Content) {
        self.router = router
        self.content = content
    }

    var body: some View {
        content()
            .sheet(item: $router.sheetItem) { item in
                item.view
            }
    }
}
//修改ViewModel, RouterTestView 中关于 Router 的调用也要修改
extension RouterTestView {
    class ViewModelModel: ObservableObject {
        @Published var router = Router()

        @Published var title: String = "The Value"
        @Published var showTitle: Bool = true

        func show() {
           router.present(content: ValueView(value: title, showValue: Binding(get: { self.showTitle }, set: { self.showTitle = $0 })))
        }

        func show<Content: View>(_ inView: Content) {
            router.present(content: inView)
        }
    }
}
  1. 注意 :在创建 sheet 内容时,必须确保每次传递给 sheet 的都是一个全新的 item 实例,以此强制重绘。

方案二:利用 @State 和自定义 Binding

这种方案绕过了 Router 中的 AnyView,直接在 RouterView 中处理 sheet 的内容,通过自定义的 Binding 来确保数据同步。

  1. 原理:
    这种方式的核心思想是,避免在 Router 中存储 AnyView。 因为 AnyView 可能会导致类型擦除和一些不可预知的问题。而是直接在 RouterView 中根据状态来决定显示哪个 sheet,通过 @State 维护 sheet 是否显示的布尔值,然后利用这个布尔值来自定义 Binding

  2. 代码示例:

class Router: ObservableObject {
    @Published var isSheetPresented = false
    @Published var sheetType: SheetType?

    //增加一个sheet类型的枚举,表明是哪个 sheet
     enum SheetType {
           case valueView
     }

    func present(sheetType: SheetType) {
        self.sheetType = sheetType
        self.isSheetPresented = true
    }
     //这里添加关闭的方法,
    func dismiss(){
        isSheetPresented = false
        sheetType = nil
    }
}

struct RouterView<Content: View>: View {
    @ObservedObject var router: Router
    @EnvironmentObject var viewModel: RouterTestView.ViewModelModel //直接注入 ViewModel

    private var content: () -> Content

    init (_ router: Router, @ViewBuilder content: @escaping () -> Content) {
        self.router = router
        self.content = content
    }

    var body: some View {
        content()
            .sheet(isPresented: $router.isSheetPresented) {
                //直接在此处处理.sheet 内容
                if router.sheetType == .valueView{
                    ValueView(value: viewModel.title, showValue: $viewModel.showTitle)
                }
            }
    }
}
//修改 ViewModel 的代码
extension RouterTestView {
    class ViewModelModel: ObservableObject {
        @Published var router = Router()

        @Published var title: String = "The Value"
        @Published var showTitle: Bool = true

        func show() {
           router.present(sheetType:.valueView)
        }
    }
}
//使用 `@EnvironmentObject` 将 ViewModel 注入到 `RouterView`。
struct RouterTestView: View {
    @StateObject var viewModel: ViewModelModel = .init()

    @State private var isPresentingSheet: Bool = false

    var body: some View {
       
        Text("showTitle == \(viewModel.showTitle ? "true" : "false")")

        Button("Present Sheet manually") {
            isPresentingSheet = true
        }
        .padding()
        .sheet(isPresented: $isPresentingSheet) {
            ValueView(value: viewModel.title, showValue: $viewModel.showTitle)
        }

        RouterView(viewModel.router) {
            Button("Present Sheet with Router") {
                viewModel.show()
            }
            .padding()
        }.environmentObject(viewModel) //注入
    }
}
  1. 优势:
    • 直接、易于理解。
    • 完全掌控 sheet 的内容和显示逻辑。
    • 不需要处理复杂的类型转换和存储。

方案三(进阶):利用PreferenceKey 强制刷新视图

如果问题是由视图更新机制不够及时引起的,可以使用 PreferenceKey 强制触发视图刷新。这个比较 hack,但是确实有效。

  1. 原理:
    PreferenceKey 允许我们在视图树中向上传递数据。可以自定义一个 PreferenceKey,每当需要强制刷新时,就改变这个 key 对应的值。 SwiftUI 检测到 key 的值发生变化,就会强制重新渲染视图。

  2. 代码示例:

// 自定义 PreferenceKey
struct ForceUpdateKey: PreferenceKey {
    static var defaultValue: Bool = false

    static func reduce(value: inout Bool, nextValue: () -> Bool) {
        value = nextValue()
    }
}

 // 在 ViewModel 里,发布更新状态
 extension RouterTestView {
  class ViewModelModel: ObservableObject {
     @Published var router = Router()
     
     @Published var title: String = "The Value"
     @Published var showTitle: Bool = true
     // 增加刷新状态的变量
     @Published var forceUpdate: Bool = false
     
     func show() {
        router.present(content: ValueView(value: title, showValue: Binding(get: { self.showTitle }, set: { self.showTitle = $0 }))
                         .background(
                              Color.clear
                               .preference(key: ForceUpdateKey.self, value: self.forceUpdate)
                                  )
                         ) // 在这里设置 Preference
     }
     
     func show<Content: View>(_ inView: Content) {
          router.present(content: inView)
     }
   }
 }
 //在 RouterTestView 里改变 preference 的值
struct RouterTestView: View {
     @StateObject var viewModel: ViewModelModel = .init()

     @State private var isPresentingSheet: Bool = false

     var body: some View {
         Text("showTitle == \(viewModel.showTitle ? "true" : "false")")

         Button("Present Sheet manually") {
             isPresentingSheet = true
         }
         .padding()
         .sheet(isPresented: $isPresentingSheet) {
             ValueView(value: viewModel.title, showValue: $viewModel.showTitle)
         }

         RouterView(viewModel.router) {
             Button("Present Sheet with Router") {
                 viewModel.show()
                 viewModel.forceUpdate.toggle() //需要刷新的时候,强制切换

             }
             .padding()
         }
     }
}

  1. 解释:
    在需要刷新的时机,简单地切换 forceUpdate 的值就行。

这三种方案都能一定程度解决因.sheet产生的Binding失效问题,选哪一种,就看你的项目具体情况和个人偏好。
通常来说,第一种方案适用与大多数的情况,如果遇到特别复杂的视图嵌套与数据传递,可以结合第一和第三种方案,兼顾稳定与灵活。