肇鑫的技术博客

业精于勤,荒于嬉

Data State And Binding in SwiftUI

In SwiftUI, the flows between data model and views are bidirectional automatically. That means, if a variable changes, the corresponding view changes too. The same is true on the reverse side.

@State

This mechanism is called binding. For variables within the same view, you can use @State property wrapper.

import SwiftUI

struct ContentView: View {
    @State var isEditing = true
    
    var body: some View {
        VStack {
            if isEditing {
                Text("Editing")
            } else {
                Text("Done")
            }
        }.onTapGesture {
            isEditing.toggle()
        }.frame(width: 200, height: 200, alignment: .center)
        .font(.title)
        .padding()
    }
}

When the code runs, the app shows a text with "Editing". If you click the text, the isEditing is toggled and app shows a text with "Done".

state_sample

@Binding

@State is used only its own view. If you need to pass @State to another view, the destination view should not use @State, but @Binding to get the @State variable.

import SwiftUI

struct AnotherSwiftUIView: View {
    @State var isEditing = true
    
    var body: some View {
        ContentView(isEditing: $isEditing)
    }
}


import SwiftUI

struct ContentView: View {
    @Binding var isEditing:Bool
    
    var body: some View {
        VStack {
            if isEditing {
                Text("Editing")
            } else {
                Text("Done")
            }
        }.onTapGesture {
            isEditing.toggle()
        }.frame(width: 200, height: 200, alignment: .center)
        .font(.title)
        .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(isEditing: .constant(true))
    }
}

For @Binding variable, you must use .constant(true) as a parameter. Also there is a $ prefix for @State as a @Binding variable.

@Binding in Closure

Many times, we may need binding in closure. For example, doing things when List is selected. However, you will find that @Binding doesn't work in parameters in closure.

import SwiftUI

struct AnotherSwiftUIView: View {
    @State var bools:[Bool] = Array(repeating: true, count: 10)
    
    var body: some View {
        List(bools, id: \.self) { isEditing in
            ContentView(isEditing: $isEditing) // Cannot find '$isEditing' in scope
        }
    }
}

That is because the parameter in closure cannot be used as @Binding.

New Trick

import SwiftUI

struct AnotherSwiftUIView: View {
    @State var bools:[Bool] = Array(repeating: true, count: 10)
    
    var body: some View {
        List(bools.indices, id: \.self) { idx in
            ContentView(isEditing: $bools[idx])
        }
    }
}

We use Array.indices and idx to get the element from the struct property, this works with @Binding.

App Crash Issue When Delete Data With Nested Bindings

@Binding is useful. But you also need to take good card of it. Some times, it leads crashes. For example,

import SwiftUI

struct ContentView: View {
    static let deleteNum = Notification.Name("deleteNum")
    @Binding var num:Int
    
    var body: some View {
        HStack {
            Text("\(num)")
            Spacer()
            Button("Delete", action:delete)
        }
    }
    
    private func delete() {
        NotificationCenter.default.post(name: ContentView.deleteNum, object: self, userInfo: ["number":num])
    }
}

import SwiftUI

struct AnotherSwiftUIView: View {
    let pub = NotificationCenter.default.publisher(for: ContentView.deleteNum)
    @State var array = [1,2,3,4,5]
    
    var body: some View {
        List(array.indices, id:\.self) { idx in
            ContentView(num: $array[idx])
        }.onReceive(pub, perform: { noti in
            if let info = noti.userInfo,
               let num = info["number"] as? Int,
               let index = array.firstIndex(of: num) {
                array.remove(at: index)
            }
        })
    }
}

struct AnotherSwiftUIView_Previews: PreviewProvider {
    static var previews: some View {
        AnotherSwiftUIView()
    }
}

nested_bindings

The app will crash when you click the "Delete" button. The issue happens because the array is changed but the array.indices are not changed accordingly in the app.
We need to double check the index.

import SwiftUI

struct AnotherSwiftUIView: View {
    let pub = NotificationCenter.default.publisher(for: ContentView.deleteNum)
    @State var array = [1,2,3,4,5]
    @State var indexSet = IndexSet()
    
    var body: some View {
        List(array.indices, id:\.self) { idx in
            Safe($array, index: idx) { binding in
                ContentView(num: binding)
            }
        }.onReceive(pub, perform: { noti in
            if let info = noti.userInfo,
               let num = info["number"] as? Int,
               let index = array.firstIndex(of: num) {
                array.remove(at: index)
            }
        })
    }
}

struct AnotherSwiftUIView_Previews: PreviewProvider {
    static var previews: some View {
        AnotherSwiftUIView()
    }
}

struct Safe<T: RandomAccessCollection & MutableCollection, C: View>: View {
   
   typealias BoundElement = Binding<T.Element>
   private let binding: BoundElement
   private let content: (BoundElement) -> C

   init(_ binding: Binding<T>, index: T.Index, @ViewBuilder content: @escaping (BoundElement) -> C) {
      self.content = content
      self.binding = .init(get: { binding.wrappedValue[index] },
                           set: { binding.wrappedValue[index] = $0 })
   }
   
   var body: some View {
      content(binding)
   }
}

References

Sidebar, Hybrid Programming SwiftUI with AppKit

You could get a NavigationView easily in SwiftUI. However, in macOS, there is an issue. If you collapse the sidebar and release your mouse, you cannot expand it again. What even worse, the state is also be remembered by the system and you could not get the sidebar even if you restart the app.

Here is a sample.

import SwiftUI

struct SidebarSwiftUIView: View {
    @State private var shouldShowPurple = false
    
    var body: some View {
        NavigationView {
            List {
                NavigationLink(
                    destination: DestinationPageView(color: .purple),
                    isActive: $shouldShowPurple
                ) {
                    Text("Purple Page")
                }
                NavigationLink(
                    destination: DestinationPageView(color: .pink)
                ) {
                    Text("Pink Page")
                }
                NavigationLink(
                    destination: DestinationPageView(color: .orange)
                ) {
                    Text("Orange Page")
                }
            }
            .frame(minWidth: 180, idealWidth: 200, maxWidth: 250, alignment: .leading)
            .navigationTitle("Colors")
            Text("Select a color page from the links.")
        }
        .onAppear {
            DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3)) {
                shouldShowPurple = true
            }
        }
    }
}

struct SidebarSwiftUIView_Previews: PreviewProvider {
    static var previews: some View {
        SidebarSwiftUIView()
    }
}

struct DestinationPageView: View {
    var color: Color
    var body: some View {
        Text("Destination Page")
            .font(.title)
            .foregroundColor(color)
            .frame(minWidth: 300, idealWidth: 450, maxWidth:.infinity)
    }
}

It runs like this.

macOS_sidebar
The issue:

macOS_collapse_sidebar

Hybrid Programming With AppKit

Hybrid SwiftUI with AppKit is easy. It takes 2 steps.

NSHostingViewController

  1. Create a NSHostingViewController in Storyboard.
  2. Create HostingViewController.swift and init it with the SwiftUIView.
import Cocoa
import SwiftUI

class HostingController: NSHostingController<SidebarSwiftUIView> {
    @objc required dynamic init?(coder: NSCoder) {
        super.init(coder: coder, rootView: SidebarSwiftUIView())
    }
}

Sidebar

Unless in iOS/iPadOS, there is no sidebar for navigation bar in macOS. So you have to do it yourself. Just simply add a toolbar in WindowController.

add_toolbar_in_window_controller

Make sure to set the tool item as Navigational in Position.

leading_the_sidebar

Toggle the Sidebar

SwiftUI will create a NSSplitViewController automatically. So we use respond chain to collapse/expand the sidebar.

import Cocoa

@main
class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        // Insert code here to initialize your application
    }

    func applicationWillTerminate(_ aNotification: Notification) {
        // Insert code here to tear down your application
    }

    @IBAction private func showSideBar(sender:Any?) {
        NSApp.keyWindow?.firstResponder?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
    }
}

Bind the action to first responder.

action_binding

Final

Now the app will look like this.

final_sidebar_window

References

Collapse sidebar in SwiftUI (Xcode 12)

closure导致的循环引用

最近在开发macOS版的咕唧2,遇到了视图控制器没有释放的问题。花了些时间进行调试。结果总结如下:

我们知道,如果存在循环引用,就会导致类无法释放。因此,我们通常会使用weak var delegate的方式来避免循环引用。

这里不谈一般的循环引用,谈谈closure的循环引用。

closure的循环引用

view controllers

如图,点击上面视图控制器的Next按钮,会自动弹出下面的视图控制器。代码如下:

import Cocoa

class ViewController: NSViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
    }

    override var representedObject: Any? {
        didSet {
        // Update the view, if already loaded.
        }
    }
}
import Cocoa

class V2ViewController: NSViewController {
    private let a = 100
    private var foo:Foo!

    override func viewDidLoad() {
        super.viewDidLoad()
        
        foo = Foo(doStaff)
        foo.run()
    }
    
    deinit {
        print("V2ViewController deinit.")
    }
    
    func doStaff() {
        print(self.a)
    }
}

class Foo {
    private(set) var bar:() -> ()
    
    init(_ bar:@escaping () -> ()) {
        self.bar = bar
    }
    
    deinit {
        print("Foo deinit.")
    }
    
    func run() {
        bar()
    }
}

运行并关闭弹出的视图控制器,控制台没有输出deinit的打印。存在内存泄漏。

分析

这是因为Foo中的bar,引用了V2ViewControllerdoStaff。而doStaff引用了self

解决

对于closure,我们不能使用weak,只能使用其它的方式。

方法1

调用完成后手动释放。

func run() {
    bar()
    bar = {}
}

方法2

还是手动释放,不过将bar定义为(()->())?,即Opitional

class Foo {
    private(set) var bar:(() -> ())?
    
    init(_ bar:@escaping () -> ()) {
        self.bar = bar
    }
    
    deinit {
        print("Foo deinit.")
    }
    
    func run() {
        bar?()
        bar = nil
    }
}

方法3

更改调用的方式,不直接分配函数,而是增加一层closure

override func viewDidLoad() {
    super.viewDidLoad()
    
    foo = Foo({ [unowned self] in
        self.doStaff()
    })
    
    foo.run()
}

通常我们认为直接分配和增加一层是等价的。但是这里我们看到,实际上,二者并不完全等价。

结论

综合起来,我认为方法3的解决方案,修改代码的成本是最低的。因为它是直接从源头解决了问题。