PointOfNilReturn
PointOfNilReturn

Reputation: 476

Implement PencilKit undo functionality using SwiftUI

Edit: Thanks to some of the feedback, I have been able to get this partially working (updated code to reflect current changes).

Even though the app appears to be working as intended, I am still getting the 'Modifying state...' warning. How can I update the view's drawing in updateUIView and push new drawings onto the stack with the canvasViewDrawingDidChange without causing this issue? I have tried wrapping it in a dispatch call, but that just creates an infinite loop.


I'm trying to implement undo functionality in a UIViewRepresentable (PKCanvasView). I have a parent SwiftUI view called WriterView which holds two buttons and the canvas.

Here's the parent view:

struct WriterView: View {
    @State var drawings: [PKDrawing] = [PKDrawing()]

    var body: some View {
        VStack(spacing: 10) {
            Button("Clear") {
                self.drawings = []
            }
            Button("Undo") {
                if !self.drawings.isEmpty {
                    self.drawings.removeLast()
                }
            }
            MyCanvas(drawings: $drawings)
        }
    }
}

Here is how I've implemented my UIViewRepresentable:

struct MyCanvas: UIViewRepresentable {
    @Binding var drawings: [PKDrawing]

    func makeUIView(context: Context) -> PKCanvasView {
        let canvas = PKCanvasView()
        canvas.delegate = context.coordinator
        return canvas
    }

    func updateUIView(_ uiView: PKCanvasView, context: Context) {
        uiView.drawing = self.drawings.last ?? PKDrawing()
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self._drawings)
    }

    class Coordinator: NSObject, PKCanvasViewDelegate {
        @Binding drawings: [PKDrawing]

        init(_ drawings: Binding<[PKDrawing]>) {
            self._drawings = drawings
        }

        func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
            drawings.append(canvasView.drawing)
        }
    }
}

I am getting the following error:

[SwiftUI] Modifying state during view update, this will cause undefined behavior.

Presumably it is being caused by my coordinator's did change function, but I'm not sure how to fix this. What is the best way to approach this?

Thanks!

Upvotes: 3

Views: 4627

Answers (5)

PointOfNilReturn
PointOfNilReturn

Reputation: 476

I finally (accidentally) figured out how to do this using UndoManager. I'm still not sure exactly why this works, because I never have to call self.undoManager?.registerUndo(). Please comment if you understand why I never have to register an event.

Here's my working parent view:

struct Writer: View {
    @Environment(\.undoManager) private var undoManager
    @State private var canvasView = PKCanvasView()
    
    var body: some View {
        VStack(spacing: 10) {
            Button("Clear") {
                canvasView.drawing = PKDrawing()
            }
            Button("Undo") {
                undoManager?.undo()
            }
            Button("Redo") {
                undoManager?.redo()
            }
            MyCanvas(canvasView: $canvasView)
        }
    }
}

Here's my working child view:

struct MyCanvas: UIViewRepresentable {
    @Binding var canvasView: PKCanvasView
    
    func makeUIView(context: Context) -> PKCanvasView {
        canvasView.drawingPolicy = .anyInput
        canvasView.tool = PKInkingTool(.pen, color: .black, width: 15)
        return canvasView
    }

    func updateUIView(_ canvasView: PKCanvasView, context: Context) { }
}

This certainly feels more like the intended approach for SwiftUI and is certainly more elegant than the attempts I made earlier.

Upvotes: 10

just for completeness and if you want to show the PKToolPicker, here is my UIViewRepresentable.

import Foundation
import SwiftUI
import PencilKit

struct PKCanvasSwiftUIView : UIViewRepresentable {

let canvasView = PKCanvasView()

#if !targetEnvironment(macCatalyst)
let coordinator = Coordinator()

class Coordinator: NSObject, PKToolPickerObserver {
    // initial values 
    var color = UIColor.black
    var thickness = CGFloat(30)

    func toolPickerSelectedToolDidChange(_ toolPicker: PKToolPicker) {
        if toolPicker.selectedTool is PKInkingTool {
            let tool = toolPicker.selectedTool as! PKInkingTool
            self.color = tool.color
            self.thickness = tool.width
        }
    }
    func toolPickerVisibilityDidChange(_ toolPicker: PKToolPicker) {
        if toolPicker.selectedTool is PKInkingTool {
            let tool = toolPicker.selectedTool as! PKInkingTool
            self.color = tool.color
            self.thickness = tool.width
        }
    }
}
func makeCoordinator() -> PKCanvasSwiftUIView.Coordinator {
    return Coordinator()
}
#endif

func makeUIView(context: Context) -> PKCanvasView {
    canvasView.isOpaque = false
    canvasView.backgroundColor = UIColor.clear
    canvasView.becomeFirstResponder()
    #if !targetEnvironment(macCatalyst)
    if let window = UIApplication.shared.windows.filter({$0.isKeyWindow}).first,
        let toolPicker = PKToolPicker.shared(for: window) {
        toolPicker.addObserver(canvasView)
        toolPicker.addObserver(coordinator)
        toolPicker.setVisible(true, forFirstResponder: canvasView)
    }
    #endif
    return canvasView
}

func updateUIView(_ uiView: PKCanvasView, context: Context) {

}
} 

Upvotes: 1

I think I have something working without the warning using a different approach.

struct ContentView: View {

let pkCntrl = PKCanvasController()

var body: some View {
    VStack(spacing: 10) {
        Button("Clear") {
            self.pkCntrl.clear()
        }
        Spacer()
        Button("Undo") {
            self.pkCntrl.undoDrawing()
        }
        Spacer()
        MyCanvas(cntrl: pkCntrl)
    }
}

}

struct MyCanvas: UIViewRepresentable {
var cntrl: PKCanvasController

func makeUIView(context: Context) -> PKCanvasView {
    cntrl.canvas = PKCanvasView()
    cntrl.canvas.delegate = context.coordinator
    cntrl.canvas.becomeFirstResponder()
    return cntrl.canvas
}

func updateUIView(_ uiView: PKCanvasView, context: Context) { }

func makeCoordinator() -> Coordinator {
    Coordinator(self)
}

class Coordinator: NSObject, PKCanvasViewDelegate {
    var parent: MyCanvas

    init(_ uiView: MyCanvas) {
        self.parent = uiView
    }

    func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
        if !self.parent.cntrl.didRemove {
            self.parent.cntrl.drawings.append(canvasView.drawing)
        }
    }
}
}

class PKCanvasController {
var canvas = PKCanvasView()
var drawings = [PKDrawing]()
var didRemove = false

func clear() {
    canvas.drawing = PKDrawing()
    drawings = [PKDrawing]()
}

func undoDrawing() {
    if !drawings.isEmpty {
        didRemove = true
        drawings.removeLast()
        canvas.drawing = drawings.last ?? PKDrawing()
        didRemove = false
    }
}
}

Upvotes: 0

I have something working with this:

struct MyCanvas: UIViewRepresentable {
@Binding var drawings: [PKDrawing]

func makeUIView(context: Context) -> PKCanvasView {
    let canvas = PKCanvasView()
    canvas.delegate = context.coordinator
    return canvas
 }

func updateUIView(_ canvas: PKCanvasView, context: Context) { }

func makeCoordinator() -> Coordinator {
    Coordinator(self._drawings)
}

class Coordinator: NSObject, PKCanvasViewDelegate {
    @Binding var drawings: [PKDrawing]

    init(_ drawings: Binding<[PKDrawing]>) {
        self._drawings = drawings
    }

    func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
        self.drawings.append(canvasView.drawing)
    }
}
}

Upvotes: 0

I think the error probably comes from the private func clearCanvas() and private func undoDrawing(). Try this to see if it works:

private func clearCanvas() {
 DispatchQueue.main.async {
    self.drawings = [PKDrawing()]
 }
}

Similarly for undoDrawing().

If it is from canvasViewDrawingDidChange, do same trick.

Upvotes: 0

Related Questions