Skip to content

UIKit backend #98

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,15 @@ switch ProcessInfo.processInfo.environment["SCUI_LIBRARY_TYPE"] {

let package = Package(
name: "swift-cross-ui",
platforms: [.macOS(.v10_15)],
platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .macCatalyst(.v13)],
products: [
.library(name: "SwiftCrossUI", type: libraryType, targets: ["SwiftCrossUI"]),
.library(name: "AppKitBackend", type: libraryType, targets: ["AppKitBackend"]),
.library(name: "GtkBackend", type: libraryType, targets: ["GtkBackend"]),
.library(name: "Gtk3Backend", type: libraryType, targets: ["Gtk3Backend"]),
.library(name: "WinUIBackend", targets: ["WinUIBackend"]),
.library(name: "DefaultBackend", type: libraryType, targets: ["DefaultBackend"]),
.library(name: "UIKitBackend", type: libraryType, targets: ["UIKitBackend"]),
.library(name: "Gtk", type: libraryType, targets: ["Gtk"]),
.executable(name: "GtkExample", targets: ["GtkExample"]),
// .library(name: "CursesBackend", type: libraryType, targets: ["CursesBackend"]),
Expand Down Expand Up @@ -145,7 +146,11 @@ let package = Package(
.target(
name: "DefaultBackend",
dependencies: [
.target(name: defaultBackend)
.target(name: defaultBackend, condition: .when(platforms: [.linux, .macOS, .windows])),
// Non-desktop platforms need to be handled separately:
// Only one backend is supported, and `#if` won't work because it's evaluated
// on the compiling desktop, not the target.
.target(name: "UIKitBackend", condition: .when(platforms: [.iOS, .tvOS, .macCatalyst])),
]
),
.target(name: "AppKitBackend", dependencies: ["SwiftCrossUI"]),
Expand Down Expand Up @@ -219,6 +224,7 @@ let package = Package(
],
swiftSettings: swiftSettings
),
.target(name: "UIKitBackend", dependencies: ["SwiftCrossUI"]),
.target(
name: "WinUIBackend",
dependencies: [
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ SwiftCrossUI has a variety of backends tailored to different operating systems.

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.

- `DefaultBackend`: Adapts to your target operating system. On macOS it uses `AppKitBackend`, on Windows it uses `WinUIBackend`, and on Linux it uses `GtkBackend`.
- `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`.
- `GtkBackend`: Works on Linux, macOS, and Windows. Requires gtk 4 to be installed. Supports all SwiftCrossUI features.
- `AppKitBackend`: The native macOS backend. Supports all SwiftCrossUI features.
- `WinUIBackend`: The native Windows backend. Supports most SwiftCrossUI features.
- `UIKitBackend`: The native iOS & tvOS backend. Supports most SwiftCrossUI features.
- `QtBackend`: ***Experimental***, requires `qt5` to be installed, and currently supports a very limited subset of SwiftCrossUI features.
- `CursesBackend`: ***Experimental***, requires `curses` to be installed, and supports a *very very* limited subset of SwiftCrossUI features.

Expand Down
25 changes: 20 additions & 5 deletions Sources/AppKitBackend/AppKitBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ public final class AppKitBackend: AppBackend {
public let defaultTableRowContentHeight = 20
public let defaultTableCellVerticalPadding = 4
public let defaultPaddingAmount = 10
public let requiresToggleSwitchSpacer = false
public let defaultToggleStyle = ToggleStyle.button

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

public func isFixedSizeWindow(_ window: Window) -> Bool {
!window.styleMask.contains(.fullScreen)
}

public func setSize(ofWindow window: Window, to newSize: SIMD2<Int>) {
window.setContentSize(NSSize(width: newSize.x, height: newSize.y))
}
Expand Down Expand Up @@ -175,18 +181,27 @@ public final class AppKitBackend: AppBackend {
public static func createDefaultAboutMenu() -> NSMenu {
let appName = ProcessInfo.processInfo.processName
let appMenu = NSMenu(title: appName)
appMenu.addItem(withTitle: "About \(appName)", action: #selector(NSApp.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
appMenu.addItem(
withTitle: "About \(appName)",
action: #selector(NSApp.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
appMenu.addItem(NSMenuItem.separator())

let hideMenu = appMenu.addItem(withTitle: "Hide \(appName)", action: #selector(NSApp.hide(_:)), keyEquivalent: "h")
let hideMenu = appMenu.addItem(
withTitle: "Hide \(appName)", action: #selector(NSApp.hide(_:)), keyEquivalent: "h")
hideMenu.keyEquivalentModifierMask = .command

let hideOthers = appMenu.addItem(withTitle: "Hide Others", action: #selector(NSApp.hideOtherApplications(_:)), keyEquivalent: "h")
let hideOthers = appMenu.addItem(
withTitle: "Hide Others", action: #selector(NSApp.hideOtherApplications(_:)),
keyEquivalent: "h")
hideOthers.keyEquivalentModifierMask = [.option, .command]

appMenu.addItem(withTitle: "Show All", action: #selector(NSApp.unhideAllApplications(_:)), keyEquivalent: "")
appMenu.addItem(
withTitle: "Show All", action: #selector(NSApp.unhideAllApplications(_:)),
keyEquivalent: "")

let quitMenu = appMenu.addItem(withTitle: "Quit \(appName)", action: #selector(NSApp.terminate(_:)), keyEquivalent: "q")
let quitMenu = appMenu.addItem(
withTitle: "Quit \(appName)", action: #selector(NSApp.terminate(_:)), keyEquivalent: "q"
)
quitMenu.keyEquivalentModifierMask = .command

return appMenu
Expand Down
3 changes: 3 additions & 0 deletions Sources/DefaultBackend/DefaultBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
#elseif canImport(CursesBackend)
import CursesBackend
public typealias DefaultBackend = CursesBackend
#elseif canImport(UIKitBackend)
import UIKitBackend
public typealias DefaultBackend = UIKitBackend
#else
#error("Unknown backend selected")
#endif
7 changes: 7 additions & 0 deletions Sources/GtkBackend/GtkBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public final class GtkBackend: AppBackend {
public let defaultTableCellVerticalPadding = 4
public let defaultPaddingAmount = 10
public let scrollBarWidth = 0
public let requiresToggleSwitchSpacer = false
public let defaultToggleStyle = ToggleStyle.button

var gtkApp: Application

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

public func isFixedSizeWindow(_ window: Window) -> Bool {
// TODO: Detect whether window is fullscreen
return false
}

public func setSize(ofWindow window: Window, to newSize: SIMD2<Int>) {
let child = window.getChild() as! CustomRootWidget
window.size = Size(
Expand Down
12 changes: 10 additions & 2 deletions Sources/SwiftCrossUI/Backend/AppBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import Foundation
/// ``AppBackend/setTitle(ofWindow:to:)``, ``AppBackend/setResizability(ofWindow:to:)``,
/// ``AppBackend/setChild(ofWindow:to:)``, ``AppBackend/show(window:)``,
/// ``AppBackend/runMainLoop()``, ``AppBackend/runInMainThread(action:)``,
/// ``AppBackend/show(widget:)``. Many of these can simply be given dummy
/// implementations until you're ready to implement them properly.
/// ``AppBackend/isFixedSizeWindow(_:)``, ``AppBackend/show(widget:)``.
/// Many of these can simply be given dummy implementations until you're ready
/// to implement them properly.
///
/// If you need to modify the children of a widget after creation but there
/// aren't update methods available, this is an intentional limitation to
Expand Down Expand Up @@ -66,6 +67,10 @@ public protocol AppBackend {
/// ensure that the configured root environment change handler gets called so
/// that SwiftCrossUI can update the app's layout accordingly.
var scrollBarWidth: Int { get }
/// If `true`, a toggle in the ``ToggleStyle/switch`` style grows to fill its parent container.
var requiresToggleSwitchSpacer: Bool { get }
/// The default style for toggles.
var defaultToggleStyle: ToggleStyle { get }

/// Often in UI frameworks (such as Gtk), code is run in a callback
/// after starting the app, and hence this generic root window creation
Expand Down Expand Up @@ -112,6 +117,9 @@ public protocol AppBackend {
func setChild(ofWindow window: Window, to child: Widget)
/// Gets the size of the given window in pixels.
func size(ofWindow window: Window) -> SIMD2<Int>
/// Check whether a window is programmatically resizable. This value does not necessarily
/// reflect whether the window is resizable by the user.
func isFixedSizeWindow(_ window: Window) -> Bool
/// Sets the size of the given window in pixels.
func setSize(ofWindow window: Window, to newSize: SIMD2<Int>)
/// Sets the minimum width and height of the window. Prevents the user from making the
Expand Down
24 changes: 11 additions & 13 deletions Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,16 @@ public final class WindowGroupNode<Content: View>: SceneGraphNode {
fatalError("Scene updated with a backend incompatible with the window it was given")
}

let isFixedSize = backend.isFixedSizeWindow(window)

_ = update(
newScene,
proposedWindowSize: isFirstUpdate
proposedWindowSize: isFirstUpdate && !isFixedSize
? (newScene ?? scene).defaultSize
: backend.size(ofWindow: window),
backend: backend,
environment: environment
environment: environment,
windowSizeIsFinal: isFixedSize
)
}

Expand Down Expand Up @@ -147,17 +150,12 @@ public final class WindowGroupNode<Content: View>: SceneGraphNode {
}
}

let finalContentResult: ViewUpdateResult
if windowSizeIsFinal {
finalContentResult = contentResult
} else {
finalContentResult = viewGraph.update(
with: newScene?.body,
proposedSize: proposedWindowSize,
environment: environment,
dryRun: false
)
}
let finalContentResult = viewGraph.update(
with: newScene?.body,
proposedSize: proposedWindowSize,
environment: environment,
dryRun: false
)

// The Gtk 3 backend has some broken sizing code that can't really be
// fixed due to the design of Gtk 3. Our layout system underestimates
Expand Down
9 changes: 8 additions & 1 deletion Sources/SwiftCrossUI/Views/Toggle.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
public struct Toggle: View {
@Environment(\.backend) var backend

/// The style of toggle shown.
var selectedToggleStyle: ToggleStyle
/// The label to be shown on or beside the toggle.
Expand All @@ -8,7 +10,7 @@ public struct Toggle: View {

/// Creates a toggle that displays a custom label.
public init(_ label: String, active: Binding<Bool>) {
self.selectedToggleStyle = .button
self.selectedToggleStyle = backend.defaultToggleStyle
self.label = label
self.active = active
}
Expand All @@ -18,6 +20,11 @@ public struct Toggle: View {
case .switch:
HStack {
Text(label)

if backend.requiresToggleSwitchSpacer {
Spacer()
}

ToggleSwitch(active: active)
}
case .button:
Expand Down
124 changes: 124 additions & 0 deletions Sources/UIKitBackend/BaseWidget.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
//
// BaseWidget.swift
// swift-cross-ui
//
// Created by William Baker on 1/10/25.
//

import UIKit

public class BaseWidget: UIView {
private var leftConstraint: NSLayoutConstraint?
private var topConstraint: NSLayoutConstraint?
private var widthConstraint: NSLayoutConstraint?
private var heightConstraint: NSLayoutConstraint?

internal var x = 0 {
didSet {
if x != oldValue {
updateLeftConstraint()
}
}
}

internal var y = 0 {
didSet {
if y != oldValue {
updateTopConstraint()
}
}
}

internal var width = 0 {
didSet {
if width != oldValue {
updateWidthConstraint()
}
}
}

internal var height = 0 {
didSet {
if height != oldValue {
updateHeightConstraint()
}
}
}

internal init() {
super.init(frame: .zero)

self.translatesAutoresizingMaskIntoConstraints = false
}

@available(*, unavailable)
public required init?(coder: NSCoder) {
fatalError("init(coder:) is not used for this view")
}

private func updateLeftConstraint() {
leftConstraint?.isActive = false
guard let superview else { return }
leftConstraint = self.leftAnchor.constraint(
equalTo: superview.safeAreaLayoutGuide.leftAnchor, constant: CGFloat(x))
leftConstraint!.isActive = true
}

private func updateTopConstraint() {
topConstraint?.isActive = false
guard let superview else { return }
topConstraint = self.topAnchor.constraint(
equalTo: superview.safeAreaLayoutGuide.topAnchor, constant: CGFloat(y))
topConstraint!.isActive = true
}

private func updateWidthConstraint() {
widthConstraint?.isActive = false
widthConstraint = self.widthAnchor.constraint(equalToConstant: CGFloat(width))
widthConstraint!.isActive = true
}

private func updateHeightConstraint() {
heightConstraint?.isActive = false
heightConstraint = self.heightAnchor.constraint(equalToConstant: CGFloat(height))
heightConstraint!.isActive = true
}

public override func didMoveToSuperview() {
super.didMoveToSuperview()

updateLeftConstraint()
updateTopConstraint()
}
}

extension UIKitBackend {
public typealias Widget = BaseWidget
}

internal class WrapperWidget<View: UIView>: BaseWidget {
init(child: View) {
super.init()

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

override convenience init() {
self.init(child: View(frame: .zero))
}

var child: View {
subviews[0] as! View
}

override var intrinsicContentSize: CGSize {
child.intrinsicContentSize
}
}
Loading
Loading