Skip to content

Commit 5533e4a

Browse files
authored
Implement UIKitBackend to support iOS, tvOS and catalyst (#98)
* UIKitBackend rough draft (woefully incomplete but arguably functional) * More progress, but something's still broken * Minor cleanup * A bunch of updates, mostly window fixes * Add alerts and compile on tvOS * final cleanup * Fix a few more things, oops * Fix buttons and toggles
1 parent 63fde71 commit 5533e4a

19 files changed

+1253
-24
lines changed

Package.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,15 @@ switch ProcessInfo.processInfo.environment["SCUI_LIBRARY_TYPE"] {
6161

6262
let package = Package(
6363
name: "swift-cross-ui",
64-
platforms: [.macOS(.v10_15)],
64+
platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .macCatalyst(.v13)],
6565
products: [
6666
.library(name: "SwiftCrossUI", type: libraryType, targets: ["SwiftCrossUI"]),
6767
.library(name: "AppKitBackend", type: libraryType, targets: ["AppKitBackend"]),
6868
.library(name: "GtkBackend", type: libraryType, targets: ["GtkBackend"]),
6969
.library(name: "Gtk3Backend", type: libraryType, targets: ["Gtk3Backend"]),
7070
.library(name: "WinUIBackend", targets: ["WinUIBackend"]),
7171
.library(name: "DefaultBackend", type: libraryType, targets: ["DefaultBackend"]),
72+
.library(name: "UIKitBackend", type: libraryType, targets: ["UIKitBackend"]),
7273
.library(name: "Gtk", type: libraryType, targets: ["Gtk"]),
7374
.executable(name: "GtkExample", targets: ["GtkExample"]),
7475
// .library(name: "CursesBackend", type: libraryType, targets: ["CursesBackend"]),
@@ -145,7 +146,11 @@ let package = Package(
145146
.target(
146147
name: "DefaultBackend",
147148
dependencies: [
148-
.target(name: defaultBackend)
149+
.target(name: defaultBackend, condition: .when(platforms: [.linux, .macOS, .windows])),
150+
// Non-desktop platforms need to be handled separately:
151+
// Only one backend is supported, and `#if` won't work because it's evaluated
152+
// on the compiling desktop, not the target.
153+
.target(name: "UIKitBackend", condition: .when(platforms: [.iOS, .tvOS, .macCatalyst])),
149154
]
150155
),
151156
.target(name: "AppKitBackend", dependencies: ["SwiftCrossUI"]),
@@ -219,6 +224,7 @@ let package = Package(
219224
],
220225
swiftSettings: swiftSettings
221226
),
227+
.target(name: "UIKitBackend", dependencies: ["SwiftCrossUI"]),
222228
.target(
223229
name: "WinUIBackend",
224230
dependencies: [

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@ SwiftCrossUI has a variety of backends tailored to different operating systems.
3030

3131
If you use `DefaultBackend`, like the examples do, you can override the default when compiling your app by setting the `SCUI_DEFAULT_BACKEND` environment variable to the name of your desired backend. This can be quite useful when you e.g. want to test the Gtk version of your app while using a Mac.
3232

33-
- `DefaultBackend`: Adapts to your target operating system. On macOS it uses `AppKitBackend`, on Windows it uses `WinUIBackend`, and on Linux it uses `GtkBackend`.
33+
- `DefaultBackend`: Adapts to your target operating system. On macOS it uses `AppKitBackend`, on Windows it uses `WinUIBackend`, on Linux it uses `GtkBackend`, and on iOS and tvOS it uses `UIKitBackend`.
3434
- `GtkBackend`: Works on Linux, macOS, and Windows. Requires gtk 4 to be installed. Supports all SwiftCrossUI features.
3535
- `AppKitBackend`: The native macOS backend. Supports all SwiftCrossUI features.
3636
- `WinUIBackend`: The native Windows backend. Supports most SwiftCrossUI features.
37+
- `UIKitBackend`: The native iOS & tvOS backend. Supports most SwiftCrossUI features.
3738
- `QtBackend`: ***Experimental***, requires `qt5` to be installed, and currently supports a very limited subset of SwiftCrossUI features.
3839
- `CursesBackend`: ***Experimental***, requires `curses` to be installed, and supports a *very very* limited subset of SwiftCrossUI features.
3940

Sources/AppKitBackend/AppKitBackend.swift

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ public final class AppKitBackend: AppBackend {
1818
public let defaultTableRowContentHeight = 20
1919
public let defaultTableCellVerticalPadding = 4
2020
public let defaultPaddingAmount = 10
21+
public let requiresToggleSwitchSpacer = false
22+
public let defaultToggleStyle = ToggleStyle.button
2123

2224
public var scrollBarWidth: Int {
2325
// We assume that all scrollers have their controlSize set to `.regular` by default.
@@ -71,6 +73,10 @@ public final class AppKitBackend: AppBackend {
7173
)
7274
}
7375

76+
public func isFixedSizeWindow(_ window: Window) -> Bool {
77+
!window.styleMask.contains(.fullScreen)
78+
}
79+
7480
public func setSize(ofWindow window: Window, to newSize: SIMD2<Int>) {
7581
window.setContentSize(NSSize(width: newSize.x, height: newSize.y))
7682
}
@@ -175,18 +181,27 @@ public final class AppKitBackend: AppBackend {
175181
public static func createDefaultAboutMenu() -> NSMenu {
176182
let appName = ProcessInfo.processInfo.processName
177183
let appMenu = NSMenu(title: appName)
178-
appMenu.addItem(withTitle: "About \(appName)", action: #selector(NSApp.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
184+
appMenu.addItem(
185+
withTitle: "About \(appName)",
186+
action: #selector(NSApp.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
179187
appMenu.addItem(NSMenuItem.separator())
180188

181-
let hideMenu = appMenu.addItem(withTitle: "Hide \(appName)", action: #selector(NSApp.hide(_:)), keyEquivalent: "h")
189+
let hideMenu = appMenu.addItem(
190+
withTitle: "Hide \(appName)", action: #selector(NSApp.hide(_:)), keyEquivalent: "h")
182191
hideMenu.keyEquivalentModifierMask = .command
183192

184-
let hideOthers = appMenu.addItem(withTitle: "Hide Others", action: #selector(NSApp.hideOtherApplications(_:)), keyEquivalent: "h")
193+
let hideOthers = appMenu.addItem(
194+
withTitle: "Hide Others", action: #selector(NSApp.hideOtherApplications(_:)),
195+
keyEquivalent: "h")
185196
hideOthers.keyEquivalentModifierMask = [.option, .command]
186197

187-
appMenu.addItem(withTitle: "Show All", action: #selector(NSApp.unhideAllApplications(_:)), keyEquivalent: "")
198+
appMenu.addItem(
199+
withTitle: "Show All", action: #selector(NSApp.unhideAllApplications(_:)),
200+
keyEquivalent: "")
188201

189-
let quitMenu = appMenu.addItem(withTitle: "Quit \(appName)", action: #selector(NSApp.terminate(_:)), keyEquivalent: "q")
202+
let quitMenu = appMenu.addItem(
203+
withTitle: "Quit \(appName)", action: #selector(NSApp.terminate(_:)), keyEquivalent: "q"
204+
)
190205
quitMenu.keyEquivalentModifierMask = .command
191206

192207
return appMenu

Sources/DefaultBackend/DefaultBackend.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
#elseif canImport(CursesBackend)
1717
import CursesBackend
1818
public typealias DefaultBackend = CursesBackend
19+
#elseif canImport(UIKitBackend)
20+
import UIKitBackend
21+
public typealias DefaultBackend = UIKitBackend
1922
#else
2023
#error("Unknown backend selected")
2124
#endif

Sources/GtkBackend/GtkBackend.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ public final class GtkBackend: AppBackend {
2727
public let defaultTableCellVerticalPadding = 4
2828
public let defaultPaddingAmount = 10
2929
public let scrollBarWidth = 0
30+
public let requiresToggleSwitchSpacer = false
31+
public let defaultToggleStyle = ToggleStyle.button
3032

3133
var gtkApp: Application
3234

@@ -117,6 +119,11 @@ public final class GtkBackend: AppBackend {
117119
return SIMD2(size.width, size.height)
118120
}
119121

122+
public func isFixedSizeWindow(_ window: Window) -> Bool {
123+
// TODO: Detect whether window is fullscreen
124+
return false
125+
}
126+
120127
public func setSize(ofWindow window: Window, to newSize: SIMD2<Int>) {
121128
let child = window.getChild() as! CustomRootWidget
122129
window.size = Size(

Sources/SwiftCrossUI/Backend/AppBackend.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ import Foundation
1111
/// ``AppBackend/setTitle(ofWindow:to:)``, ``AppBackend/setResizability(ofWindow:to:)``,
1212
/// ``AppBackend/setChild(ofWindow:to:)``, ``AppBackend/show(window:)``,
1313
/// ``AppBackend/runMainLoop()``, ``AppBackend/runInMainThread(action:)``,
14-
/// ``AppBackend/show(widget:)``. Many of these can simply be given dummy
15-
/// implementations until you're ready to implement them properly.
14+
/// ``AppBackend/isFixedSizeWindow(_:)``, ``AppBackend/show(widget:)``.
15+
/// Many of these can simply be given dummy implementations until you're ready
16+
/// to implement them properly.
1617
///
1718
/// If you need to modify the children of a widget after creation but there
1819
/// aren't update methods available, this is an intentional limitation to
@@ -66,6 +67,10 @@ public protocol AppBackend {
6667
/// ensure that the configured root environment change handler gets called so
6768
/// that SwiftCrossUI can update the app's layout accordingly.
6869
var scrollBarWidth: Int { get }
70+
/// If `true`, a toggle in the ``ToggleStyle/switch`` style grows to fill its parent container.
71+
var requiresToggleSwitchSpacer: Bool { get }
72+
/// The default style for toggles.
73+
var defaultToggleStyle: ToggleStyle { get }
6974

7075
/// Often in UI frameworks (such as Gtk), code is run in a callback
7176
/// after starting the app, and hence this generic root window creation
@@ -112,6 +117,9 @@ public protocol AppBackend {
112117
func setChild(ofWindow window: Window, to child: Widget)
113118
/// Gets the size of the given window in pixels.
114119
func size(ofWindow window: Window) -> SIMD2<Int>
120+
/// Check whether a window is programmatically resizable. This value does not necessarily
121+
/// reflect whether the window is resizable by the user.
122+
func isFixedSizeWindow(_ window: Window) -> Bool
115123
/// Sets the size of the given window in pixels.
116124
func setSize(ofWindow window: Window, to newSize: SIMD2<Int>)
117125
/// Sets the minimum width and height of the window. Prevents the user from making the

Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,16 @@ public final class WindowGroupNode<Content: View>: SceneGraphNode {
6666
fatalError("Scene updated with a backend incompatible with the window it was given")
6767
}
6868

69+
let isFixedSize = backend.isFixedSizeWindow(window)
70+
6971
_ = update(
7072
newScene,
71-
proposedWindowSize: isFirstUpdate
73+
proposedWindowSize: isFirstUpdate && !isFixedSize
7274
? (newScene ?? scene).defaultSize
7375
: backend.size(ofWindow: window),
7476
backend: backend,
75-
environment: environment
77+
environment: environment,
78+
windowSizeIsFinal: isFixedSize
7679
)
7780
}
7881

@@ -147,17 +150,12 @@ public final class WindowGroupNode<Content: View>: SceneGraphNode {
147150
}
148151
}
149152

150-
let finalContentResult: ViewUpdateResult
151-
if windowSizeIsFinal {
152-
finalContentResult = contentResult
153-
} else {
154-
finalContentResult = viewGraph.update(
155-
with: newScene?.body,
156-
proposedSize: proposedWindowSize,
157-
environment: environment,
158-
dryRun: false
159-
)
160-
}
153+
let finalContentResult = viewGraph.update(
154+
with: newScene?.body,
155+
proposedSize: proposedWindowSize,
156+
environment: environment,
157+
dryRun: false
158+
)
161159

162160
// The Gtk 3 backend has some broken sizing code that can't really be
163161
// fixed due to the design of Gtk 3. Our layout system underestimates

Sources/SwiftCrossUI/Views/Toggle.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
public struct Toggle: View {
2+
@Environment(\.backend) var backend
3+
24
/// The style of toggle shown.
35
var selectedToggleStyle: ToggleStyle
46
/// The label to be shown on or beside the toggle.
@@ -8,7 +10,7 @@ public struct Toggle: View {
810

911
/// Creates a toggle that displays a custom label.
1012
public init(_ label: String, active: Binding<Bool>) {
11-
self.selectedToggleStyle = .button
13+
self.selectedToggleStyle = backend.defaultToggleStyle
1214
self.label = label
1315
self.active = active
1416
}
@@ -18,6 +20,11 @@ public struct Toggle: View {
1820
case .switch:
1921
HStack {
2022
Text(label)
23+
24+
if backend.requiresToggleSwitchSpacer {
25+
Spacer()
26+
}
27+
2128
ToggleSwitch(active: active)
2229
}
2330
case .button:

Sources/UIKitBackend/BaseWidget.swift

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
//
2+
// BaseWidget.swift
3+
// swift-cross-ui
4+
//
5+
// Created by William Baker on 1/10/25.
6+
//
7+
8+
import UIKit
9+
10+
public class BaseWidget: UIView {
11+
private var leftConstraint: NSLayoutConstraint?
12+
private var topConstraint: NSLayoutConstraint?
13+
private var widthConstraint: NSLayoutConstraint?
14+
private var heightConstraint: NSLayoutConstraint?
15+
16+
internal var x = 0 {
17+
didSet {
18+
if x != oldValue {
19+
updateLeftConstraint()
20+
}
21+
}
22+
}
23+
24+
internal var y = 0 {
25+
didSet {
26+
if y != oldValue {
27+
updateTopConstraint()
28+
}
29+
}
30+
}
31+
32+
internal var width = 0 {
33+
didSet {
34+
if width != oldValue {
35+
updateWidthConstraint()
36+
}
37+
}
38+
}
39+
40+
internal var height = 0 {
41+
didSet {
42+
if height != oldValue {
43+
updateHeightConstraint()
44+
}
45+
}
46+
}
47+
48+
internal init() {
49+
super.init(frame: .zero)
50+
51+
self.translatesAutoresizingMaskIntoConstraints = false
52+
}
53+
54+
@available(*, unavailable)
55+
public required init?(coder: NSCoder) {
56+
fatalError("init(coder:) is not used for this view")
57+
}
58+
59+
private func updateLeftConstraint() {
60+
leftConstraint?.isActive = false
61+
guard let superview else { return }
62+
leftConstraint = self.leftAnchor.constraint(
63+
equalTo: superview.safeAreaLayoutGuide.leftAnchor, constant: CGFloat(x))
64+
leftConstraint!.isActive = true
65+
}
66+
67+
private func updateTopConstraint() {
68+
topConstraint?.isActive = false
69+
guard let superview else { return }
70+
topConstraint = self.topAnchor.constraint(
71+
equalTo: superview.safeAreaLayoutGuide.topAnchor, constant: CGFloat(y))
72+
topConstraint!.isActive = true
73+
}
74+
75+
private func updateWidthConstraint() {
76+
widthConstraint?.isActive = false
77+
widthConstraint = self.widthAnchor.constraint(equalToConstant: CGFloat(width))
78+
widthConstraint!.isActive = true
79+
}
80+
81+
private func updateHeightConstraint() {
82+
heightConstraint?.isActive = false
83+
heightConstraint = self.heightAnchor.constraint(equalToConstant: CGFloat(height))
84+
heightConstraint!.isActive = true
85+
}
86+
87+
public override func didMoveToSuperview() {
88+
super.didMoveToSuperview()
89+
90+
updateLeftConstraint()
91+
updateTopConstraint()
92+
}
93+
}
94+
95+
extension UIKitBackend {
96+
public typealias Widget = BaseWidget
97+
}
98+
99+
internal class WrapperWidget<View: UIView>: BaseWidget {
100+
init(child: View) {
101+
super.init()
102+
103+
self.addSubview(child)
104+
child.translatesAutoresizingMaskIntoConstraints = false
105+
NSLayoutConstraint.activate([
106+
child.topAnchor.constraint(equalTo: self.topAnchor),
107+
child.leadingAnchor.constraint(equalTo: self.leadingAnchor),
108+
child.bottomAnchor.constraint(equalTo: self.bottomAnchor),
109+
child.trailingAnchor.constraint(equalTo: self.trailingAnchor),
110+
])
111+
}
112+
113+
override convenience init() {
114+
self.init(child: View(frame: .zero))
115+
}
116+
117+
var child: View {
118+
subviews[0] as! View
119+
}
120+
121+
override var intrinsicContentSize: CGSize {
122+
child.intrinsicContentSize
123+
}
124+
}

0 commit comments

Comments
 (0)