// SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI struct DrawingPadUndoRedoStroke: Equatable { var points: [CGPoint] } /// The strokes on the pad plus the ones that can be brought back. struct DrawingPadUndoRedoHistory: Equatable { private(set) var strokes: [DrawingPadUndoRedoStroke] private(set) var undone: [DrawingPadUndoRedoStroke] = [] init(strokes: [DrawingPadUndoRedoStroke] = []) { self.strokes = strokes } var canUndo: Bool { !strokes.isEmpty } var canRedo: Bool { !undone.isEmpty } /// A new stroke starts a new future, so what was undone can no longer be redone. mutating func add(_ stroke: DrawingPadUndoRedoStroke) { guard !stroke.points.isEmpty else { return } strokes.append(stroke) undone.removeAll() } mutating func undo() { guard let stroke = strokes.popLast() else { return } undone.append(stroke) } mutating func redo() { guard let stroke = undone.popLast() else { return } strokes.append(stroke) } mutating func clear() { strokes.removeAll() undone.removeAll() } } /// A drawing pad that keeps its strokes in a history, with undo, redo and clear buttons. /// /// A stroke the system interrupts is kept up to the point where it stopped. struct DrawingPadUndoRedo: View { @Binding var history: DrawingPadUndoRedoHistory var color: Color = .primary var lineWidth: CGFloat = 6 @State private var currentPoints: [CGPoint] = [] @GestureState private var isDrawing = false @Environment(\.dynamicTypeSize) private var dynamicTypeSize var body: some View { VStack(spacing: 14) { pad toolbar } } private var pad: some View { Canvas { context, _ in let shading = GraphicsContext.Shading.color(color) let width = max(lineWidth, 0.5) for stroke in history.strokes { Self.draw(stroke.points, width: width, shading: shading, in: context) } Self.draw(currentPoints, width: width, shading: shading, in: context) } .clipped() .contentShape(.rect) .gesture( DragGesture(minimumDistance: 0) .updating($isDrawing) { _, state, _ in state = true } .onChanged { currentPoints.append($0.location) } ) .onChange(of: isDrawing) { _, drawing in guard !drawing else { return } history.add(DrawingPadUndoRedoStroke(points: currentPoints)) currentPoints = [] } .accessibilityElement() .accessibilityLabel("Drawing pad") .accessibilityValue("^[\(history.strokes.count) stroke](inflect: true)") .accessibilityAddTraits(.allowsDirectInteraction) } @ViewBuilder private var toolbar: some View { if dynamicTypeSize.isAccessibilitySize { toolbarButtons.labelStyle(.iconOnly) } else { toolbarButtons.labelStyle(.titleAndIcon) } } private var toolbarButtons: some View { HStack(spacing: 10) { Button("Undo", systemImage: "arrow.uturn.backward") { history.undo() } .disabled(!history.canUndo) Button("Redo", systemImage: "arrow.uturn.forward") { history.redo() } .disabled(!history.canRedo) Spacer(minLength: 0) Button("Clear", systemImage: "trash", role: .destructive) { history.clear() } .disabled(!history.canUndo && !history.canRedo) } .buttonStyle(.bordered) } private static func draw(_ points: [CGPoint], width: CGFloat, shading: GraphicsContext.Shading, in context: GraphicsContext) { guard let first = points.first else { return } guard points.count > 1 else { let dot = CGRect(x: first.x - width / 2, y: first.y - width / 2, width: width, height: width) context.fill(Path(ellipseIn: dot), with: shading) return } var path = Path() path.move(to: first) for index in 1..