Skip to content

Commit 6866a8c

Browse files
Add Easel package
This package will be used to house the content for Apple Pencil interactions.
1 parent 095d92f commit 6866a8c

File tree

6 files changed

+299
-0
lines changed

6 files changed

+299
-0
lines changed

Alidade.xcworkspace/contents.xcworkspacedata

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Easel/.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
xcuserdata/
5+
DerivedData/
6+
.swiftpm/configuration/registries.json
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8+
.netrc

Easel/Package.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// swift-tools-version: 6.0
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "Easel",
8+
platforms: [.macOS(.v15), .iOS(.v18)],
9+
products: [
10+
// Products define the executables and libraries a package produces, making them visible to other packages.
11+
.library(
12+
name: "Easel",
13+
targets: ["Easel"]
14+
),
15+
],
16+
targets: [
17+
// Targets are the basic building blocks of a package, defining a module or a test suite.
18+
// Targets can depend on other targets in this package and products from dependencies.
19+
.target(
20+
name: "Easel"
21+
),
22+
.testTarget(
23+
name: "EaselTests",
24+
dependencies: ["Easel"]
25+
),
26+
]
27+
)
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
//
2+
// EaselView.swift
3+
// Easel
4+
//
5+
// Created by Marquis Kurt on 25-08-2025.
6+
//
7+
8+
import MapKit
9+
import PencilKit
10+
import SwiftUI
11+
12+
/// A view that provides a canvas layer for creating drawings using the Apple Pencil.
13+
///
14+
/// Easel views are used to allow players to draw over content views, such as maps. The tool picker can be shown or
15+
/// hidden programmatically, and the drawing is bound for quick access and storage.
16+
@available(iOS 18.0, *)
17+
public struct EaselView<CanvasBackground: View> {
18+
/// The coordinator used to listen for delegate events.
19+
public class Coordinator: NSObject, EaselViewControllerDelegate {
20+
@Binding var drawing: PKDrawing
21+
22+
init(drawing: Binding<PKDrawing>) {
23+
self._drawing = drawing
24+
}
25+
26+
func easelViewController(_: EaselViewController, didChangeDrawing drawing: PKDrawing) {
27+
self.drawing = drawing
28+
}
29+
}
30+
31+
@Binding var drawing: PKDrawing
32+
@Binding var isToolPickerPresented: Bool
33+
34+
private var drawingPolicy: PKCanvasViewDrawingPolicy
35+
private var canvasBackground: (() -> CanvasBackground)?
36+
37+
/// Create an easel view with a drawing.
38+
/// - Parameter drawing: The drawing that the easel view will manage.
39+
/// - Parameter canvasBackground: The background to use.
40+
public init(
41+
drawing: Binding<PKDrawing>,
42+
canvasBackground: (() -> CanvasBackground)? = nil
43+
) {
44+
self._drawing = drawing
45+
self._isToolPickerPresented = .constant(true)
46+
self.drawingPolicy = .default
47+
self.canvasBackground = canvasBackground
48+
}
49+
50+
private init(
51+
drawing: Binding<PKDrawing>,
52+
picker: Binding<Bool>,
53+
policy: PKCanvasViewDrawingPolicy,
54+
background: (() -> CanvasBackground)?
55+
) {
56+
self._drawing = drawing
57+
self._isToolPickerPresented = picker
58+
self.drawingPolicy = policy
59+
self.canvasBackground = background
60+
}
61+
62+
/// Sets the drawing policy on the easel view.
63+
/// - Parameter policy: The easel view's drawing policy.
64+
public func drawingPolicy(_ policy: PKCanvasViewDrawingPolicy) -> EaselView {
65+
EaselView(
66+
drawing: $drawing,
67+
picker: $isToolPickerPresented,
68+
policy: policy,
69+
background: canvasBackground
70+
)
71+
}
72+
73+
/// Sets the visibility of the tool picker.
74+
///
75+
/// When the tool picker is presented, the background view loses its interaction capabilities to prevent
76+
/// interference between the canvas and the background. User interaction with the background is restored when the
77+
/// tool picker is hidden.
78+
///
79+
/// - Parameter isPresented: Whether the tool picker should be visible.
80+
public func toolPicker(isPresented: Binding<Bool>) -> EaselView {
81+
EaselView(
82+
drawing: $drawing,
83+
picker: isPresented,
84+
policy: drawingPolicy,
85+
background: canvasBackground
86+
)
87+
}
88+
}
89+
90+
extension EaselView where CanvasBackground == EmptyView {
91+
/// Create an easel view with no background.
92+
/// - Parameter drawing: The drawing that the easel will manage.
93+
public init(drawing: Binding<PKDrawing>) {
94+
self._drawing = drawing
95+
self._isToolPickerPresented = .constant(true)
96+
self.drawingPolicy = .default
97+
self.canvasBackground = nil
98+
}
99+
}
100+
101+
extension EaselView: UIViewControllerRepresentable {
102+
public typealias UIViewControllerType = EaselViewController
103+
104+
public func makeCoordinator() -> Coordinator {
105+
Coordinator(drawing: $drawing)
106+
}
107+
108+
public func makeUIViewController(context: Context) -> EaselViewController {
109+
let viewController = EaselViewController()
110+
viewController.easelViewDelegate = context.coordinator
111+
if let canvasBackground {
112+
let hostingController = UIHostingController(rootView: canvasBackground())
113+
viewController.canvasBackgroundController = hostingController
114+
}
115+
if isToolPickerPresented {
116+
viewController.activateToolPicker()
117+
}
118+
return viewController
119+
}
120+
121+
public func updateUIViewController(_ uiViewController: EaselViewController, context: Context) {
122+
if isToolPickerPresented {
123+
uiViewController.activateToolPicker()
124+
} else {
125+
uiViewController.deactivateToolPicker()
126+
}
127+
}
128+
}
129+
130+
#if os(iOS)
131+
#Preview {
132+
@Previewable @State var drawing = PKDrawing()
133+
@Previewable @State var toolPickerActive = true
134+
135+
NavigationStack {
136+
EaselView(drawing: $drawing) {
137+
Map(interactionModes: .all)
138+
}
139+
.toolPicker(isPresented: $toolPickerActive)
140+
.drawingPolicy(.anyInput)
141+
.navigationTitle("Drawing Canvas")
142+
.navigationBarTitleDisplayMode(.inline)
143+
.toolbarRole(.editor)
144+
.ignoresSafeArea()
145+
.toolbar {
146+
Button("Tools", systemImage: "pencil.tip.crop.circle") {
147+
toolPickerActive.toggle()
148+
}
149+
}
150+
}
151+
}
152+
#endif
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
//
2+
// EaselViewController.swift
3+
// Easel
4+
//
5+
// Created by Marquis Kurt on 25-08-2025.
6+
//
7+
8+
import PencilKit
9+
10+
protocol EaselViewControllerDelegate: AnyObject {
11+
func easelViewController(_ viewController: EaselViewController, didChangeDrawing drawing: PKDrawing)
12+
}
13+
14+
#if canImport(UIKit)
15+
import UIKit
16+
17+
public class EaselViewController: UIViewController {
18+
private lazy var drawingCanvas: PKCanvasView = {
19+
let canvasView = PKCanvasView(frame: .zero)
20+
canvasView.translatesAutoresizingMaskIntoConstraints = false
21+
canvasView.drawingPolicy = drawingPolicy
22+
canvasView.isOpaque = false
23+
canvasView.backgroundColor = .clear
24+
return canvasView
25+
}()
26+
27+
var drawingPolicy: PKCanvasViewDrawingPolicy = .default {
28+
didSet { drawingCanvas.drawingPolicy = self.drawingPolicy }
29+
}
30+
31+
var canvasBackgroundController: UIViewController? {
32+
didSet { didSetCanvasBackgroundController() }
33+
}
34+
35+
weak var easelViewDelegate: EaselViewControllerDelegate?
36+
37+
private var toolPicker = PKToolPicker()
38+
39+
init() {
40+
super.init(nibName: nil, bundle: nil)
41+
}
42+
43+
@available(*, unavailable)
44+
required init?(coder: NSCoder) {
45+
fatalError("init(coder:) not implemented. Are you using storyboards?")
46+
}
47+
48+
public override func viewDidLoad() {
49+
super.viewDidLoad()
50+
51+
view.addSubview(drawingCanvas)
52+
NSLayoutConstraint.activate([
53+
drawingCanvas.topAnchor.constraint(equalTo: view.topAnchor),
54+
drawingCanvas.leadingAnchor.constraint(equalTo: view.leadingAnchor),
55+
drawingCanvas.trailingAnchor.constraint(equalTo: view.trailingAnchor),
56+
drawingCanvas.bottomAnchor.constraint(equalTo: view.bottomAnchor)
57+
])
58+
59+
toolPicker.addObserver(drawingCanvas)
60+
toolPicker.setVisible(true, forFirstResponder: drawingCanvas)
61+
}
62+
63+
func activateToolPicker() {
64+
if drawingCanvas.isFirstResponder { return }
65+
drawingCanvas.becomeFirstResponder()
66+
canvasBackgroundController?.view.isUserInteractionEnabled = false
67+
}
68+
69+
func deactivateToolPicker() {
70+
if !drawingCanvas.isFirstResponder { return }
71+
drawingCanvas.resignFirstResponder()
72+
drawingCanvas.isUserInteractionEnabled = false
73+
if let canvasBackgroundController {
74+
canvasBackgroundController.view.isUserInteractionEnabled = true
75+
canvasBackgroundController.becomeFirstResponder()
76+
}
77+
}
78+
79+
private func didSetCanvasBackgroundController() {
80+
if let canvasBackgroundController {
81+
canvasBackgroundController.view.translatesAutoresizingMaskIntoConstraints = false
82+
addChild(canvasBackgroundController)
83+
drawingCanvas.isOpaque = false
84+
drawingCanvas.backgroundColor = .clear
85+
view.insertSubview(canvasBackgroundController.view, at: 0)
86+
87+
NSLayoutConstraint.activate([
88+
canvasBackgroundController.view.topAnchor.constraint(equalTo: view.topAnchor),
89+
canvasBackgroundController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
90+
canvasBackgroundController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
91+
canvasBackgroundController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
92+
])
93+
canvasBackgroundController.didMove(toParent: self)
94+
}
95+
}
96+
}
97+
98+
extension EaselViewController: PKCanvasViewDelegate {
99+
public func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
100+
easelViewDelegate?.easelViewController(self, didChangeDrawing: canvasView.drawing)
101+
}
102+
}
103+
#endif
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import Testing
2+
@testable import Easel
3+
4+
@Test func example() async throws {
5+
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
6+
}

0 commit comments

Comments
 (0)