Skip to content
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
21 changes: 21 additions & 0 deletions Sources/SwiftCrossUI/Environment/EnvironmentValues.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ public struct EnvironmentValues {
/// a bottom-up update chain up which resize events can propagate.
var onResize: (_ newSize: ViewSize) -> Void

// Backing storage for extensible subscript
private var extraValues: [ObjectIdentifier: Any]

public subscript<T: EnvironmentKey>(_ key: T.Type) -> T.Value {
get {
extraValues[ObjectIdentifier(T.self), default: T.defaultValue] as! T.Value
}
set {
extraValues[ObjectIdentifier(T.self)] = newValue
}
}

/// Brings the current window forward, not guaranteed to always bring
/// the window to the top (due to focus stealing prevention).
func bringWindowForward() {
Expand Down Expand Up @@ -121,6 +133,7 @@ public struct EnvironmentValues {
colorScheme = .light
windowScaleFactor = 1
window = nil
extraValues = [:]
}

/// Returns a copy of the environment with the specified property set to the
Expand All @@ -131,3 +144,11 @@ public struct EnvironmentValues {
return environment
}
}

/// A key that can be used to extend the environment with new properties.
public protocol EnvironmentKey {
/// The type of value the key can hold.
associatedtype Value
/// The default value for the key.
static var defaultValue: Value { get }
}
10 changes: 5 additions & 5 deletions Sources/SwiftCrossUI/Modifiers/EnvironmentModifier.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
struct EnvironmentModifier<Child: View>: View {
var body: TupleView1<Child>
package struct EnvironmentModifier<Child: View>: View {
package var body: TupleView1<Child>
var modification: (EnvironmentValues) -> EnvironmentValues

init(_ child: Child, modification: @escaping (EnvironmentValues) -> EnvironmentValues) {
package init(_ child: Child, modification: @escaping (EnvironmentValues) -> EnvironmentValues) {
self.body = TupleView1(child)
self.modification = modification
}

func children<Backend: AppBackend>(
package func children<Backend: AppBackend>(
backend: Backend,
snapshots: [ViewGraphSnapshotter.NodeSnapshot]?,
environment: EnvironmentValues
Expand All @@ -19,7 +19,7 @@ struct EnvironmentModifier<Child: View>: View {
)
}

func update<Backend: AppBackend>(
package func update<Backend: AppBackend>(
_ widget: Backend.Widget,
children: any ViewGraphNodeChildren,
proposedSize: SIMD2<Int>,
Expand Down
4 changes: 2 additions & 2 deletions Sources/SwiftCrossUI/Views/Button.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/// A control that initiates an action.
public struct Button: ElementaryView, View {
/// The label to show on the button.
var label: String
package var label: String
/// The action to be performed when the button is clicked.
var action: () -> Void
package var action: () -> Void
/// The button's forced width if provided.
var width: Int?

Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftCrossUI/Views/Spacer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
public struct Spacer: ElementaryView, View {
/// The minimum length this spacer can be shrunk to, along the axis of
/// expansion.
private var minLength: Int?
package var minLength: Int?

/// Creates a spacer with a given minimum length along its axis or axes
/// of expansion.
Expand Down
181 changes: 181 additions & 0 deletions Sources/UIKitBackend/KeyboardToolbar.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import SwiftCrossUI
import UIKit

/// An item which can be displayed in a keyboard toolbar. Implementers of this do not have
/// to implement ``SwiftCrossUI/View``.
///
/// Toolbar items are expected to be "stateless". Mutations of `@State` properties of toolbar
/// items will not cause the toolbar to be updated. The toolbar is only updated when the view
/// containing the ``View/keyboardToolbar(animateChanges:body:)`` modifier is updated, so any
/// state necessary for the toolbar should live in the view itself.
public protocol ToolbarItem {
/// Convert the item to a `UIBarButtonItem`, which will be placed in the keyboard toolbar.
func asBarButtonItem() -> UIBarButtonItem
}

@resultBuilder
public enum ToolbarBuilder {
public typealias Component = [any ToolbarItem]

public static func buildExpression(_ expression: some ToolbarItem) -> Component {
[expression]
}

public static func buildExpression(_ expression: any ToolbarItem) -> Component {
[expression]
}

public static func buildBlock(_ components: Component...) -> Component {
components.flatMap { $0 }
}

public static func buildArray(_ components: [Component]) -> Component {
components.flatMap { $0 }
}

public static func buildOptional(_ component: Component?) -> Component {
component ?? []
}

public static func buildEither(first component: Component) -> Component {
component
}

public static func buildEither(second component: Component) -> Component {
component
}
}

final class CallbackBarButtonItem: UIBarButtonItem {
private var callback: () -> Void

init(title: String, callback: @escaping () -> Void) {
self.callback = callback
super.init()

self.title = title
self.target = self
self.action = #selector(onTap)
}

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

@objc
func onTap() {
callback()
}
}

extension Button: ToolbarItem {
public func asBarButtonItem() -> UIBarButtonItem {
CallbackBarButtonItem(title: label, callback: action)
}
}

@available(iOS 14, macCatalyst 14, tvOS 14, *)
extension Spacer: ToolbarItem {
public func asBarButtonItem() -> UIBarButtonItem {
if let minLength, minLength > 0 {
print(
"""
Warning: Spacer's minLength property is ignored within keyboard toolbars \
due to UIKit limitations. Use `Spacer()` for unconstrained spacers and \
`Spacer().frame(width: _)` for fixed-length spacers.
"""
)
}
return .flexibleSpace()
}
}

struct FixedWidthToolbarItem<Base: ToolbarItem>: ToolbarItem {
var base: Base
var width: Int?

func asBarButtonItem() -> UIBarButtonItem {
let item = base.asBarButtonItem()
if let width {
item.width = CGFloat(width)
}
return item
}
}

// Setting width on a flexible space is ignored, you must use a fixed space from the outset
@available(iOS 14, macCatalyst 14, tvOS 14, *)
struct FixedWidthSpacerItem: ToolbarItem {
var width: Int?

func asBarButtonItem() -> UIBarButtonItem {
if let width {
.fixedSpace(CGFloat(width))
} else {
.flexibleSpace()
}
}
}

struct ColoredToolbarItem<Base: ToolbarItem>: ToolbarItem {
var base: Base
var color: Color

func asBarButtonItem() -> UIBarButtonItem {
let item = base.asBarButtonItem()
item.tintColor = color.uiColor
return item
}
}

extension ToolbarItem {
/// A toolbar item with the specified width.
///
/// If `width` is positive, the item will have that exact width. If `width` is zero or
/// nil, the item will have its natural size.
public func frame(width: Int?) -> any ToolbarItem {
if #available(iOS 14, macCatalyst 14, tvOS 14, *),
self is Spacer || self is FixedWidthSpacerItem
{
FixedWidthSpacerItem(width: width)
} else {
FixedWidthToolbarItem(base: self, width: width)
}
}

/// A toolbar item with the specified foreground color.
public func foregroundColor(_ color: Color) -> some ToolbarItem {
ColoredToolbarItem(base: self, color: color)
}
}

enum ToolbarKey: EnvironmentKey {
static let defaultValue: ((UIToolbar) -> Void)? = nil
}

extension EnvironmentValues {
var updateToolbar: ((UIToolbar) -> Void)? {
get { self[ToolbarKey.self] }
set { self[ToolbarKey.self] = newValue }
}
}

extension View {
/// Set a toolbar that will be shown above the keyboard for text fields within this view.
/// - Parameters:
/// - animateChanges: Whether to animate updates when an item is added, removed, or
/// updated
/// - body: The toolbar's contents
public func keyboardToolbar(
animateChanges: Bool = true,
@ToolbarBuilder body: @escaping () -> ToolbarBuilder.Component
) -> some View {
EnvironmentModifier(self) { environment in
environment.with(\.updateToolbar) { toolbar in
toolbar.setItems(body().map { $0.asBarButtonItem() }, animated: animateChanges)
toolbar.sizeToFit()
}
}
}
}
8 changes: 8 additions & 0 deletions Sources/UIKitBackend/UIKitBackend+Control.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,14 @@ extension UIKitBackend {
textFieldWidget.child.textColor = UIColor(color: environment.suggestedForegroundColor)
textFieldWidget.onChange = onChange
textFieldWidget.onSubmit = onSubmit

if let updateToolbar = environment.updateToolbar {
let toolbar = (textFieldWidget.child.inputAccessoryView as? UIToolbar) ?? UIToolbar()
updateToolbar(toolbar)
textFieldWidget.child.inputAccessoryView = toolbar
} else {
textFieldWidget.child.inputAccessoryView = nil
}
}

public func setContent(ofTextField textField: Widget, to content: String) {
Expand Down
Loading