Skip to content

Commit

Permalink
[UIKitBackend] Custom Application Delegates & UIViewRepresentable (#…
Browse files Browse the repository at this point in the history
…103)

* Allow custom UIApplicationDelegates

* Implement UIViewRepresentable

* Address PR comments

* Improved sizing logic
  • Loading branch information
bbrk24 authored Jan 17, 2025
1 parent 667dc9d commit 20a6e6f
Show file tree
Hide file tree
Showing 2 changed files with 271 additions and 8 deletions.
72 changes: 64 additions & 8 deletions Sources/UIKitBackend/UIKitBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,18 @@ public final class UIKitBackend: AppBackend {
public let defaultTableRowContentHeight = -1
public let defaultTableCellVerticalPadding = -1

public init() {}

var onTraitCollectionChange: (() -> Void)?

private let appDelegateClass: ApplicationDelegate.Type

public init() {
self.appDelegateClass = ApplicationDelegate.self
}

public init(appDelegateClass: ApplicationDelegate.Type) {
self.appDelegateClass = appDelegateClass
}

public func runMainLoop(
_ callback: @escaping () -> Void
) {
Expand All @@ -33,7 +42,7 @@ public final class UIKitBackend: AppBackend {
CommandLine.argc,
CommandLine.unsafeArgv,
NSStringFromClass(UIApplication.self),
NSStringFromClass(ApplicationDelegate.self)
NSStringFromClass(appDelegateClass)
)
}

Expand Down Expand Up @@ -99,8 +108,33 @@ extension App {
}
}

class ApplicationDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow? {
/// The root class for application delegates of SwiftCrossUI apps.
///
/// In order to use a custom application delegate, pass your class to ``UIKitBackend/init(appDelegateClass:)``:
///
/// ```swift
/// import SwiftCrossUI
/// import UIKitBackend
///
/// class MyAppDelegate: ApplicationDelegate {
/// // UIApplicationDelegate methods here
/// }
///
/// @main
/// struct SwiftCrossUI_TestApp: App {
/// var backend: UIKitBackend {
/// UIKitBackend(appDelegateClass: MyAppDelegate.self)
/// }
///
/// var body: some Scene {
/// WindowGroup {
/// // View code here
/// }
/// }
/// }
/// ```
open class ApplicationDelegate: UIResponder, UIApplicationDelegate {
public var window: UIWindow? {
get {
UIKitBackend.mainWindow
}
Expand All @@ -109,7 +143,16 @@ class ApplicationDelegate: UIResponder, UIApplicationDelegate {
}
}

func applicationDidBecomeActive(_ application: UIApplication) {
public required override init() {
super.init()
}

/// Tells the delegate that the app has become active.
///
/// - Important: If you override this method in a subclass, you must call
/// `super.applicationDidBecomeActive(application)` as the first step of your
/// implementation.
open func applicationDidBecomeActive(_ application: UIApplication) {
UIKitBackend.onBecomeActive?()

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

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

func application(
/// Asks the delegate to open a resource specified by a URL, and provides a dictionary of launch options.
///
/// If you override this method in a subclass, you should call
/// `super.application(app, open: url, options: options` at some point in your
/// implementation. You do not necessarily have to return the same value as this `super`
/// call.
open func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
Expand Down
207 changes: 207 additions & 0 deletions Sources/UIKitBackend/UIViewRepresentable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import SwiftCrossUI
import UIKit

public struct UIViewRepresentableContext<Coordinator> {
public let coordinator: Coordinator
public internal(set) var environment: EnvironmentValues
}

public protocol UIViewRepresentable: View
where Content == Never {
associatedtype UIViewType: UIView
associatedtype Coordinator = Void

/// Create the initial UIView instance.
func makeUIView(context: UIViewRepresentableContext<Coordinator>) -> UIViewType

/// Update the view with new values.
/// - Parameters:
/// - uiView: The view to update.
/// - context: The context, including the coordinator and potentially new environment
/// values.
/// - Note: This may be called even when `context` has not changed.
func updateUIView(_ uiView: UIViewType, context: UIViewRepresentableContext<Coordinator>)

/// Make the coordinator for this view.
///
/// The coordinator is used when the view needs to communicate changes to the rest of
/// the view hierarchy (i.e. through bindings), and is often the view's delegate.
func makeCoordinator() -> Coordinator

/// Compute the view's size.
/// - Parameters:
/// - proposal: The proposed frame for the view to render in.
/// - uiVIew: The view being queried for its preferred size.
/// - context: The context, including the coordinator and environment values.
/// - Returns: Information about the view's size. The ``SwiftCrossUI/ViewSize/size``
/// property is what frame the view will actually be rendered with if the current layout
/// pass is not a dry run, while the other properties are used to inform the layout engine
/// how big or small the view can be. The ``SwiftCrossUI/ViewSize/idealSize`` property
/// should not vary with the `proposal`, and should only depend on the view's contents.
/// Pass `nil` for the maximum width/height if the view has no maximum size (and therefore
/// may occupy the entire screen).
///
/// The default implementation uses `uiView.intrinsicContentSize` and `uiView.sizeThatFits(_:)`
/// to determine the return value.
func determineViewSize(
for proposal: SIMD2<Int>, uiView: UIViewType,
context: UIViewRepresentableContext<Coordinator>
) -> ViewSize

/// Called to clean up the view when it's removed.
/// - Parameters:
/// - uiVIew: The view being dismantled.
/// - coordinator: The coordinator.
///
/// This method is called after all UIKit lifecycle methods, such as
/// `uiView.didMoveToSuperview()`.
///
/// The default implementation does nothing.
static func dismantleUIView(_ uiView: UIViewType, coordinator: Coordinator)
}

extension UIViewRepresentable {
public static func dismantleUIView(_: UIViewType, coordinator _: Coordinator) {
// no-op
}

public func determineViewSize(
for proposal: SIMD2<Int>, uiView: UIViewType,
context _: UIViewRepresentableContext<Coordinator>
) -> ViewSize {
let intrinsicSize = uiView.intrinsicContentSize
let sizeThatFits = uiView.sizeThatFits(
CGSize(width: CGFloat(proposal.x), height: CGFloat(proposal.y)))

let roundedSizeThatFits = SIMD2(
Int(sizeThatFits.width.rounded(.up)),
Int(sizeThatFits.height.rounded(.up)))
let roundedIntrinsicSize = SIMD2(
Int(intrinsicSize.width.rounded(.awayFromZero)),
Int(intrinsicSize.height.rounded(.awayFromZero)))

return ViewSize(
size: SIMD2(
intrinsicSize.width < 0.0 ? proposal.x : roundedSizeThatFits.x,
intrinsicSize.height < 0.0 ? proposal.y : roundedSizeThatFits.y
),
// The 10 here is a somewhat arbitrary constant value so that it's always the same.
// See also `Color` and `Picker`, which use the same constant.
idealSize: SIMD2(
intrinsicSize.width < 0.0 ? 10 : roundedIntrinsicSize.x,
intrinsicSize.height < 0.0 ? 10 : roundedIntrinsicSize.y
),
minimumWidth: max(0, roundedIntrinsicSize.x),
minimumHeight: max(0, roundedIntrinsicSize.x),
maximumWidth: nil,
maximumHeight: nil
)
}
}

extension View
where Self: UIViewRepresentable {
public var body: Never {
preconditionFailure("This should never be called")
}

public func children<Backend: AppBackend>(
backend _: Backend,
snapshots _: [ViewGraphSnapshotter.NodeSnapshot]?,
environment _: EnvironmentValues
) -> any ViewGraphNodeChildren {
EmptyViewChildren()
}

public func layoutableChildren<Backend: AppBackend>(
backend _: Backend,
children _: any ViewGraphNodeChildren
) -> [LayoutSystem.LayoutableChild] {
[]
}

public func asWidget<Backend: AppBackend>(
_: any ViewGraphNodeChildren,
backend _: Backend
) -> Backend.Widget {
if let widget = RepresentingWidget(representable: self) as? Backend.Widget {
return widget
} else {
fatalError("UIViewRepresentable requested by \(Backend.self)")
}
}

public func update<Backend: AppBackend>(
_ widget: Backend.Widget,
children _: any ViewGraphNodeChildren,
proposedSize: SIMD2<Int>,
environment: EnvironmentValues,
backend _: Backend,
dryRun: Bool
) -> ViewUpdateResult {
let representingWidget = widget as! RepresentingWidget<Self>
representingWidget.update(with: environment)

let size =
representingWidget.representable.determineViewSize(
for: proposedSize,
uiView: representingWidget.subview,
context: representingWidget.context!
)

if !dryRun {
representingWidget.width = size.size.x
representingWidget.height = size.size.y
}

return ViewUpdateResult.leafView(size: size)
}
}

extension UIViewRepresentable
where Coordinator == Void {
public func makeCoordinator() {
return ()
}
}

final class RepresentingWidget<Representable: UIViewRepresentable>: BaseWidget {
var representable: Representable
var context: UIViewRepresentableContext<Representable.Coordinator>?

lazy var subview: Representable.UIViewType = {
let view = representable.makeUIView(context: context!)

self.addSubview(view)

view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
view.topAnchor.constraint(equalTo: self.topAnchor),
view.leadingAnchor.constraint(equalTo: self.leadingAnchor),
view.trailingAnchor.constraint(equalTo: self.trailingAnchor),
view.bottomAnchor.constraint(equalTo: self.bottomAnchor),
])

return view
}()

func update(with environment: EnvironmentValues) {
if context == nil {
context = .init(coordinator: representable.makeCoordinator(), environment: environment)
} else {
context!.environment = environment
representable.updateUIView(subview, context: context!)
}
}

init(representable: Representable) {
self.representable = representable
super.init()
}

deinit {
if let context {
Representable.dismantleUIView(subview, coordinator: context.coordinator)
}
}
}

0 comments on commit 20a6e6f

Please sign in to comment.