swiftuipencilkit

How to dismiss PKToolPicker when view disappears in SwiftUI?


I'm trying to wrap PKCanvasView as a SwiftUI view called CanvasView. I'd like to be able to toggle the whole canvas on top of another view. When the CanvasView appears, I'd like the PKToolPicker to appear. When it disappears, I'd like the PKToolPicker to disappear.

I've found a few similar approaches on here but they only involve showing the picker or toggling the picker with a button; I'd like the picker visibility to be tied to the view visibility.

In the below example you can see that you can toggle the canvas, but once the tool picker is visible, it stays visible.

Here's my CanvasView:

import SwiftUI
import PencilKit

struct CanvasView: UIViewRepresentable {

    class Coordinator: NSObject, PKCanvasViewDelegate {
        var canvasView: Binding<PKCanvasView>
        let onChange: () -> Void

        init(canvasView: Binding<PKCanvasView>, onChange: @escaping () -> Void) {
            self.canvasView = canvasView
            self.onChange = onChange
        }

        func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
            if canvasView.drawing.bounds.isEmpty == false {
                onChange()
            }
        }
    }

    @Binding var canvasView: PKCanvasView
    @Binding var toolPickerIsActive: Bool
    private let toolPicker = PKToolPicker()

    let onChange: () -> Void

    func makeUIView(context: Context) -> PKCanvasView {
        canvasView.backgroundColor = .clear
        canvasView.isOpaque = true
        canvasView.delegate = context.coordinator
        showToolPicker()

        return canvasView
    }

    func updateUIView(_ uiView: PKCanvasView, context: Context) {
        toolPicker.setVisible(toolPickerIsActive, forFirstResponder: uiView)
    }

    func showToolPicker() {
      toolPicker.setVisible(true, forFirstResponder: canvasView)
      toolPicker.addObserver(canvasView)
      canvasView.becomeFirstResponder()
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(canvasView: $canvasView, onChange: onChange)
    }
}

And an example ContentView:

struct ContentView: View {
    @State private var canvasView = PKCanvasView()
    @State private var toolPickerIsActive = false
    @State private var canvasIsVisible = false

    var body: some View {
        ZStack {
            if canvasIsVisible {
                CanvasView(canvasView: $canvasView,
                           toolPickerIsActive: $toolPickerIsActive,
                           onChange: canvasDidChange)
                    .onAppear { toolPickerIsActive = true }
                    .onDisappear { toolPickerIsActive = false }
            }

            Button(action: {
                canvasIsVisible.toggle()
            }, label: {
                Text("Toggle canvas view")
            })
        }
    }

    private func canvasDidChange() {
        // Do something with updated canvas.
    }
}

Any guidance would be much appreciated!


Solution

  • In your scenario the CanvasView is destroyed on disappear, so SwiftUI rendering engine just not update it on any state change (as it see that no needs for that).

    The possible solution for this use-case is to hide picker on coordinator deinit (because it is destroyed with owner view).

    Here is a demo. Tested with Xcode 12.4 / iOS 14.4

    demo

    struct CanvasView: UIViewRepresentable {
    
        class Coordinator: NSObject, PKCanvasViewDelegate {
            var canvasView: Binding<PKCanvasView>
            let onChange: () -> Void
            private let toolPicker: PKToolPicker
    
            deinit {       // << here !!
                toolPicker.setVisible(false, forFirstResponder: canvasView.wrappedValue)
                toolPicker.removeObserver(canvasView.wrappedValue)
            }
    
            init(canvasView: Binding<PKCanvasView>, toolPicker: PKToolPicker, onChange: @escaping () -> Void) {
                self.canvasView = canvasView
                self.onChange = onChange
                self.toolPicker = toolPicker
            }
    
            func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
                if canvasView.drawing.bounds.isEmpty == false {
                    onChange()
                }
            }
        }
    
        @Binding var canvasView: PKCanvasView
        @Binding var toolPickerIsActive: Bool
        private let toolPicker = PKToolPicker()
    
        let onChange: () -> Void
    
        func makeUIView(context: Context) -> PKCanvasView {
            canvasView.backgroundColor = .clear
            canvasView.isOpaque = true
            canvasView.delegate = context.coordinator
            showToolPicker()
    
            return canvasView
        }
    
        func updateUIView(_ uiView: PKCanvasView, context: Context) {
            toolPicker.setVisible(toolPickerIsActive, forFirstResponder: uiView)
        }
    
        func showToolPicker() {
            toolPicker.setVisible(true, forFirstResponder: canvasView)
            toolPicker.addObserver(canvasView)
            canvasView.becomeFirstResponder()
        }
    
        func makeCoordinator() -> Coordinator {
            Coordinator(canvasView: $canvasView, toolPicker: toolPicker, onChange: onChange)
        }
    }
    
    struct ContentView: View {
        @State private var canvasView = PKCanvasView()
        @State private var toolPickerIsActive = false
        @State private var canvasIsVisible = false
    
        var body: some View {
            ZStack {
                if canvasIsVisible {
                    CanvasView(canvasView: $canvasView,
                                  toolPickerIsActive: $toolPickerIsActive,
                                  onChange: canvasDidChange)
                        .onAppear { toolPickerIsActive = true }
    //                    .onDisappear { toolPickerIsActive = false }
                }
    
                Button(action: {
                    canvasIsVisible.toggle()
                }, label: {
                    Text("Toggle canvas view")
                })
            }
        }
    
        private func canvasDidChange() {
            // Do something with updated canvas.
        }
    }
    

    Note: there might be redesign of ownership, so toolPicker will live only within coordinator, but it does not change idea, and is up to you.