肇鑫的技术博客

业精于勤,荒于嬉

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)

The Simplest Sample for Dragging Operation on UITableView

One of my friend requires an app feature for Poster 2 which requires drag operation on UITableView.

I did a research and found two resolutions. They all worked. However, they were all too complex. I wanted to find the simplest way to implemented the operation. Here it is.

Theory

There are two ways to do the tricks. One is to use UITableViewDiffableDataSource, the other is to use UITableViewDataSource. This article is focus on the latter. If you want to know the prior, just find the link at the end of this article.

override func viewDidLoad() {
    super.viewDidLoad()
    
    tableView.dragInteractionEnabled = true
    tableView.dataSource = self
    tableView.dragDelegate = self
}

// MARK: - UITableViewDataSource
extension ViewController:UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        cell.textLabel?.text = items[indexPath.row]
        
        return cell
    }

// MARK: - UITableViewDragDelegate
extension ViewController:UITableViewDragDelegate {
    func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
        // return [] // return [] won't stop the table view from draging. However, Apple wants us to do it on our own.
        
        return [UIDragItem(itemProvider: NSItemProvider(object: items[indexPath.row] as NSItemProviderWriting))]
    }
}

tableView.dragInteractionEnabled = true this line must not be the last line of the three-line code, or the drag operation won't work.

In docs, Apple says "Return an empty array to indicate that you do not want the specified row to be dragged." This requirement is for you to implement. Even if you return [] here, the drag operation still works.

The above code is enough for UITableView dragging operation. When you drag, the cell that you drag will move to the destination you drop. However, like all other operations on UITableView, you need to keep the model's consistence with the UI.

func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
        guard sourceIndexPath != destinationIndexPath else {
            return
        }
        
        let item = items[sourceIndexPath.row]
        
        if sourceIndexPath.row < destinationIndexPath.row {
            items.insert(item, at: destinationIndexPath.row + 1)
            items.remove(at: sourceIndexPath.row)
        } else {
            items.remove(at: sourceIndexPath.row)
            items.insert(item, at: destinationIndexPath.row)
        }
        
        items.forEach {
            print($0)
        }
        print()
    }
}

References

Sample For UITableViewDiffableDataSource catalyst_reorder_example

Apple's Official Sample Adopting Drag and Drop in a Table View

My Own Sample