2

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!

PointOfNilReturn
  • 416
  • 4
  • 13

5 Answers5

9

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.

PointOfNilReturn
  • 416
  • 4
  • 13
  • This looks like a much better solution. I saw a good explanation of the environment at: https://swiftwithmajid.com/2019/08/21/the-power-of-environment-in-swiftui/ It basically says that this is a pre-defined system wide environment variable that is passed in for every View. All done for you. In my own code, on iPad the drawing tool has built in undo/redo buttons but not on iPhone. On Mac (catalist) there is no tool at all, you have to do it yourself, and so I thought that's what you were coding for. – workingdog support Ukraine Mar 12 '20 at 06:30
  • The only way this can be improved is if the clear button can be made undo-able... but I got no idea how to do that right now :sad: – royalmurder Mar 18 '20 at 16:24
1

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) {

}
} 
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.

  • Unfortunately, this did not help. I tried isolating the issue by removing the clear and undo functions. It seems like this is some problem with `updateUIView` and `canvasViewDrawingDidChange`. I guess at some point I need to push the new drawing onto the stack but not while the body is rendering again? – PointOfNilReturn Mar 09 '20 at 02:40
  • I notice you have "[PKDrawing()]" , should this be [PKDrawing]() – workingdog support Ukraine Mar 09 '20 at 02:53
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)
    }
}
}
  • This almost works. The view was not reflecting the drawings array and so I added `uiView.drawing = self.drawings.last ?? PKDrawing()` to the `updateUIView` function. Even though it appears to work now, it still gives me the same warning. Do you have any suggestions? – PointOfNilReturn Mar 09 '20 at 05:16
  • I've tried a number of things, such as using @ObservedObject, but nothing works. I can't get rid of the warning. – workingdog support Ukraine Mar 10 '20 at 08:28
0

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
    }
}
}