Skip to content

Commit 20a6e6f

Browse files
authored
[UIKitBackend] Custom Application Delegates & UIViewRepresentable (#103)
* Allow custom UIApplicationDelegates * Implement UIViewRepresentable * Address PR comments * Improved sizing logic
1 parent 667dc9d commit 20a6e6f

File tree

2 files changed

+271
-8
lines changed

2 files changed

+271
-8
lines changed

Sources/UIKitBackend/UIKitBackend.swift

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,18 @@ public final class UIKitBackend: AppBackend {
1919
public let defaultTableRowContentHeight = -1
2020
public let defaultTableCellVerticalPadding = -1
2121

22-
public init() {}
23-
2422
var onTraitCollectionChange: (() -> Void)?
23+
24+
private let appDelegateClass: ApplicationDelegate.Type
25+
26+
public init() {
27+
self.appDelegateClass = ApplicationDelegate.self
28+
}
29+
30+
public init(appDelegateClass: ApplicationDelegate.Type) {
31+
self.appDelegateClass = appDelegateClass
32+
}
33+
2534
public func runMainLoop(
2635
_ callback: @escaping () -> Void
2736
) {
@@ -33,7 +42,7 @@ public final class UIKitBackend: AppBackend {
3342
CommandLine.argc,
3443
CommandLine.unsafeArgv,
3544
NSStringFromClass(UIApplication.self),
36-
NSStringFromClass(ApplicationDelegate.self)
45+
NSStringFromClass(appDelegateClass)
3746
)
3847
}
3948

@@ -99,8 +108,33 @@ extension App {
99108
}
100109
}
101110

102-
class ApplicationDelegate: UIResponder, UIApplicationDelegate {
103-
var window: UIWindow? {
111+
/// The root class for application delegates of SwiftCrossUI apps.
112+
///
113+
/// In order to use a custom application delegate, pass your class to ``UIKitBackend/init(appDelegateClass:)``:
114+
///
115+
/// ```swift
116+
/// import SwiftCrossUI
117+
/// import UIKitBackend
118+
///
119+
/// class MyAppDelegate: ApplicationDelegate {
120+
/// // UIApplicationDelegate methods here
121+
/// }
122+
///
123+
/// @main
124+
/// struct SwiftCrossUI_TestApp: App {
125+
/// var backend: UIKitBackend {
126+
/// UIKitBackend(appDelegateClass: MyAppDelegate.self)
127+
/// }
128+
///
129+
/// var body: some Scene {
130+
/// WindowGroup {
131+
/// // View code here
132+
/// }
133+
/// }
134+
/// }
135+
/// ```
136+
open class ApplicationDelegate: UIResponder, UIApplicationDelegate {
137+
public var window: UIWindow? {
104138
get {
105139
UIKitBackend.mainWindow
106140
}
@@ -109,7 +143,16 @@ class ApplicationDelegate: UIResponder, UIApplicationDelegate {
109143
}
110144
}
111145

112-
func applicationDidBecomeActive(_ application: UIApplication) {
146+
public required override init() {
147+
super.init()
148+
}
149+
150+
/// Tells the delegate that the app has become active.
151+
///
152+
/// - Important: If you override this method in a subclass, you must call
153+
/// `super.applicationDidBecomeActive(application)` as the first step of your
154+
/// implementation.
155+
open func applicationDidBecomeActive(_ application: UIApplication) {
113156
UIKitBackend.onBecomeActive?()
114157

115158
// We only want to notify the first time. Otherwise the app's view
@@ -118,7 +161,14 @@ class ApplicationDelegate: UIResponder, UIApplicationDelegate {
118161
UIKitBackend.onBecomeActive = nil
119162
}
120163

121-
func application(
164+
/// Tells the delegate that the launch process is almost done and the app is almost ready
165+
/// to run.
166+
///
167+
/// If you override this method in a subclass, you should call
168+
/// `super.application(application, didFinishLaunchingWithOptions: launchOptions)`
169+
/// at some point in your implementation. You do not necessarily have to return the same
170+
/// value as this `super` call.
171+
open func application(
122172
_ application: UIApplication,
123173
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
124174
) -> Bool {
@@ -131,7 +181,13 @@ class ApplicationDelegate: UIResponder, UIApplicationDelegate {
131181
return true
132182
}
133183

134-
func application(
184+
/// Asks the delegate to open a resource specified by a URL, and provides a dictionary of launch options.
185+
///
186+
/// If you override this method in a subclass, you should call
187+
/// `super.application(app, open: url, options: options` at some point in your
188+
/// implementation. You do not necessarily have to return the same value as this `super`
189+
/// call.
190+
open func application(
135191
_ app: UIApplication,
136192
open url: URL,
137193
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import SwiftCrossUI
2+
import UIKit
3+
4+
public struct UIViewRepresentableContext<Coordinator> {
5+
public let coordinator: Coordinator
6+
public internal(set) var environment: EnvironmentValues
7+
}
8+
9+
public protocol UIViewRepresentable: View
10+
where Content == Never {
11+
associatedtype UIViewType: UIView
12+
associatedtype Coordinator = Void
13+
14+
/// Create the initial UIView instance.
15+
func makeUIView(context: UIViewRepresentableContext<Coordinator>) -> UIViewType
16+
17+
/// Update the view with new values.
18+
/// - Parameters:
19+
/// - uiView: The view to update.
20+
/// - context: The context, including the coordinator and potentially new environment
21+
/// values.
22+
/// - Note: This may be called even when `context` has not changed.
23+
func updateUIView(_ uiView: UIViewType, context: UIViewRepresentableContext<Coordinator>)
24+
25+
/// Make the coordinator for this view.
26+
///
27+
/// The coordinator is used when the view needs to communicate changes to the rest of
28+
/// the view hierarchy (i.e. through bindings), and is often the view's delegate.
29+
func makeCoordinator() -> Coordinator
30+
31+
/// Compute the view's size.
32+
/// - Parameters:
33+
/// - proposal: The proposed frame for the view to render in.
34+
/// - uiVIew: The view being queried for its preferred size.
35+
/// - context: The context, including the coordinator and environment values.
36+
/// - Returns: Information about the view's size. The ``SwiftCrossUI/ViewSize/size``
37+
/// property is what frame the view will actually be rendered with if the current layout
38+
/// pass is not a dry run, while the other properties are used to inform the layout engine
39+
/// how big or small the view can be. The ``SwiftCrossUI/ViewSize/idealSize`` property
40+
/// should not vary with the `proposal`, and should only depend on the view's contents.
41+
/// Pass `nil` for the maximum width/height if the view has no maximum size (and therefore
42+
/// may occupy the entire screen).
43+
///
44+
/// The default implementation uses `uiView.intrinsicContentSize` and `uiView.sizeThatFits(_:)`
45+
/// to determine the return value.
46+
func determineViewSize(
47+
for proposal: SIMD2<Int>, uiView: UIViewType,
48+
context: UIViewRepresentableContext<Coordinator>
49+
) -> ViewSize
50+
51+
/// Called to clean up the view when it's removed.
52+
/// - Parameters:
53+
/// - uiVIew: The view being dismantled.
54+
/// - coordinator: The coordinator.
55+
///
56+
/// This method is called after all UIKit lifecycle methods, such as
57+
/// `uiView.didMoveToSuperview()`.
58+
///
59+
/// The default implementation does nothing.
60+
static func dismantleUIView(_ uiView: UIViewType, coordinator: Coordinator)
61+
}
62+
63+
extension UIViewRepresentable {
64+
public static func dismantleUIView(_: UIViewType, coordinator _: Coordinator) {
65+
// no-op
66+
}
67+
68+
public func determineViewSize(
69+
for proposal: SIMD2<Int>, uiView: UIViewType,
70+
context _: UIViewRepresentableContext<Coordinator>
71+
) -> ViewSize {
72+
let intrinsicSize = uiView.intrinsicContentSize
73+
let sizeThatFits = uiView.sizeThatFits(
74+
CGSize(width: CGFloat(proposal.x), height: CGFloat(proposal.y)))
75+
76+
let roundedSizeThatFits = SIMD2(
77+
Int(sizeThatFits.width.rounded(.up)),
78+
Int(sizeThatFits.height.rounded(.up)))
79+
let roundedIntrinsicSize = SIMD2(
80+
Int(intrinsicSize.width.rounded(.awayFromZero)),
81+
Int(intrinsicSize.height.rounded(.awayFromZero)))
82+
83+
return ViewSize(
84+
size: SIMD2(
85+
intrinsicSize.width < 0.0 ? proposal.x : roundedSizeThatFits.x,
86+
intrinsicSize.height < 0.0 ? proposal.y : roundedSizeThatFits.y
87+
),
88+
// The 10 here is a somewhat arbitrary constant value so that it's always the same.
89+
// See also `Color` and `Picker`, which use the same constant.
90+
idealSize: SIMD2(
91+
intrinsicSize.width < 0.0 ? 10 : roundedIntrinsicSize.x,
92+
intrinsicSize.height < 0.0 ? 10 : roundedIntrinsicSize.y
93+
),
94+
minimumWidth: max(0, roundedIntrinsicSize.x),
95+
minimumHeight: max(0, roundedIntrinsicSize.x),
96+
maximumWidth: nil,
97+
maximumHeight: nil
98+
)
99+
}
100+
}
101+
102+
extension View
103+
where Self: UIViewRepresentable {
104+
public var body: Never {
105+
preconditionFailure("This should never be called")
106+
}
107+
108+
public func children<Backend: AppBackend>(
109+
backend _: Backend,
110+
snapshots _: [ViewGraphSnapshotter.NodeSnapshot]?,
111+
environment _: EnvironmentValues
112+
) -> any ViewGraphNodeChildren {
113+
EmptyViewChildren()
114+
}
115+
116+
public func layoutableChildren<Backend: AppBackend>(
117+
backend _: Backend,
118+
children _: any ViewGraphNodeChildren
119+
) -> [LayoutSystem.LayoutableChild] {
120+
[]
121+
}
122+
123+
public func asWidget<Backend: AppBackend>(
124+
_: any ViewGraphNodeChildren,
125+
backend _: Backend
126+
) -> Backend.Widget {
127+
if let widget = RepresentingWidget(representable: self) as? Backend.Widget {
128+
return widget
129+
} else {
130+
fatalError("UIViewRepresentable requested by \(Backend.self)")
131+
}
132+
}
133+
134+
public func update<Backend: AppBackend>(
135+
_ widget: Backend.Widget,
136+
children _: any ViewGraphNodeChildren,
137+
proposedSize: SIMD2<Int>,
138+
environment: EnvironmentValues,
139+
backend _: Backend,
140+
dryRun: Bool
141+
) -> ViewUpdateResult {
142+
let representingWidget = widget as! RepresentingWidget<Self>
143+
representingWidget.update(with: environment)
144+
145+
let size =
146+
representingWidget.representable.determineViewSize(
147+
for: proposedSize,
148+
uiView: representingWidget.subview,
149+
context: representingWidget.context!
150+
)
151+
152+
if !dryRun {
153+
representingWidget.width = size.size.x
154+
representingWidget.height = size.size.y
155+
}
156+
157+
return ViewUpdateResult.leafView(size: size)
158+
}
159+
}
160+
161+
extension UIViewRepresentable
162+
where Coordinator == Void {
163+
public func makeCoordinator() {
164+
return ()
165+
}
166+
}
167+
168+
final class RepresentingWidget<Representable: UIViewRepresentable>: BaseWidget {
169+
var representable: Representable
170+
var context: UIViewRepresentableContext<Representable.Coordinator>?
171+
172+
lazy var subview: Representable.UIViewType = {
173+
let view = representable.makeUIView(context: context!)
174+
175+
self.addSubview(view)
176+
177+
view.translatesAutoresizingMaskIntoConstraints = false
178+
NSLayoutConstraint.activate([
179+
view.topAnchor.constraint(equalTo: self.topAnchor),
180+
view.leadingAnchor.constraint(equalTo: self.leadingAnchor),
181+
view.trailingAnchor.constraint(equalTo: self.trailingAnchor),
182+
view.bottomAnchor.constraint(equalTo: self.bottomAnchor),
183+
])
184+
185+
return view
186+
}()
187+
188+
func update(with environment: EnvironmentValues) {
189+
if context == nil {
190+
context = .init(coordinator: representable.makeCoordinator(), environment: environment)
191+
} else {
192+
context!.environment = environment
193+
representable.updateUIView(subview, context: context!)
194+
}
195+
}
196+
197+
init(representable: Representable) {
198+
self.representable = representable
199+
super.init()
200+
}
201+
202+
deinit {
203+
if let context {
204+
Representable.dismantleUIView(subview, coordinator: context.coordinator)
205+
}
206+
}
207+
}

0 commit comments

Comments
 (0)