diff --git a/Examples/Sources/ControlsExample/ControlsApp.swift b/Examples/Sources/ControlsExample/ControlsApp.swift index fe94e13ee8..11a71344f1 100644 --- a/Examples/Sources/ControlsExample/ControlsApp.swift +++ b/Examples/Sources/ControlsExample/ControlsApp.swift @@ -16,71 +16,83 @@ struct ControlsApp: App { @State var text = "" @State var flavor: String? = nil @State var enabled = true + @State var progressViewSize: Int = 10 + @State var isProgressViewResizable = true var body: some Scene { WindowGroup("ControlsApp") { #hotReloadable { - VStack(spacing: 30) { - VStack { - Text("Button") - Button("Click me!") { - count += 1 + ScrollView { + VStack(spacing: 30) { + VStack { + Text("Button") + Button("Click me!") { + count += 1 + } + Text("Count: \(count)") } - Text("Count: \(count)") - } - .padding(.bottom, 20) + .padding(.bottom, 20) + + #if !canImport(UIKitBackend) + VStack { + Text("Toggle button") + Toggle("Toggle me!", active: $exampleButtonState) + .toggleStyle(.button) + Text("Currently enabled: \(exampleButtonState)") + } + .padding(.bottom, 20) + #endif - #if !canImport(UIKitBackend) VStack { - Text("Toggle button") - Toggle("Toggle me!", active: $exampleButtonState) - .toggleStyle(.button) - Text("Currently enabled: \(exampleButtonState)") + Text("Toggle switch") + Toggle("Toggle me:", active: $exampleSwitchState) + .toggleStyle(.switch) + Text("Currently enabled: \(exampleSwitchState)") } - .padding(.bottom, 20) - #endif - VStack { - Text("Toggle switch") - Toggle("Toggle me:", active: $exampleSwitchState) - .toggleStyle(.switch) - Text("Currently enabled: \(exampleSwitchState)") - } + #if !canImport(UIKitBackend) + VStack { + Text("Checkbox") + Toggle("Toggle me:", active: $exampleCheckboxState) + .toggleStyle(.checkbox) + Text("Currently enabled: \(exampleCheckboxState)") + } + #endif - #if !canImport(UIKitBackend) VStack { - Text("Checkbox") - Toggle("Toggle me:", active: $exampleCheckboxState) - .toggleStyle(.checkbox) - Text("Currently enabled: \(exampleCheckboxState)") + Text("Slider") + Slider($sliderValue, minimum: 0, maximum: 10) + .frame(maxWidth: 200) + Text("Value: \(String(format: "%.02f", sliderValue))") } - #endif - VStack { - Text("Slider") - Slider($sliderValue, minimum: 0, maximum: 10) - .frame(maxWidth: 200) - Text("Value: \(String(format: "%.02f", sliderValue))") - } + VStack { + Text("Text field") + TextField("Text field", text: $text) + Text("Value: \(text)") + } - VStack { - Text("Text field") - TextField("Text field", text: $text) - Text("Value: \(text)") - } + Toggle("Enable ProgressView resizability", active: $isProgressViewResizable) + Slider($progressViewSize, minimum: 10, maximum: 100) + ProgressView() + .resizable(isProgressViewResizable) + .frame(width: progressViewSize, height: progressViewSize) - VStack { - Text("Drop down") - HStack { - Text("Flavor: ") - Picker(of: ["Vanilla", "Chocolate", "Strawberry"], selection: $flavor) + VStack { + Text("Drop down") + HStack { + Text("Flavor: ") + Picker( + of: ["Vanilla", "Chocolate", "Strawberry"], selection: $flavor) + } + Text("You chose: \(flavor ?? "Nothing yet!")") } - Text("You chose: \(flavor ?? "Nothing yet!")") - } - }.padding().disabled(!enabled) + }.padding().disabled(!enabled) - Toggle(enabled ? "Disable all" : "Enable all", active: $enabled) - .padding() + Toggle(enabled ? "Disable all" : "Enable all", active: $enabled) + .padding() + } + .frame(minHeight: 600) } }.defaultSize(width: 400, height: 600) } diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 1d2d2e7487..d5f1d7b253 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -485,6 +485,15 @@ public final class AppKitBackend: AppBackend { } public func naturalSize(of widget: Widget) -> SIMD2 { + if let spinner = widget.subviews.first as? NSProgressIndicator, + spinner.style == .spinning + { + let size = spinner.intrinsicContentSize + return SIMD2( + Int(size.width), + Int(size.height) + ) + } let size = widget.intrinsicContentSize return SIMD2( Int(size.width), @@ -1168,11 +1177,32 @@ public final class AppKitBackend: AppBackend { } public func createProgressSpinner() -> Widget { + let container = NSView() + let spinner = NSProgressIndicator() + spinner.translatesAutoresizingMaskIntoConstraints = false + spinner.isIndeterminate = true + spinner.style = .spinning + spinner.startAnimation(nil) + container.addSubview(spinner) + return container + } + + public func setSize( + ofProgressSpinner widget: Widget, + to size: SIMD2 + ) { + guard Int(widget.frame.size.height) != size.y else { return } + setSize(of: widget, to: size) let spinner = NSProgressIndicator() + spinner.translatesAutoresizingMaskIntoConstraints = false spinner.isIndeterminate = true spinner.style = .spinning spinner.startAnimation(nil) - return spinner + spinner.widthAnchor.constraint(equalToConstant: CGFloat(size.x)).isActive = true + spinner.heightAnchor.constraint(equalToConstant: CGFloat(size.y)).isActive = true + + widget.subviews = [] + widget.addSubview(spinner) } public func createProgressBar() -> Widget { diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index c0209a76e7..d7e02b669d 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -550,6 +550,16 @@ public protocol AppBackend: Sendable { /// Creates an indeterminate progress spinner. func createProgressSpinner() -> Widget + /// Sets the size of a progress spinner. + /// + /// This method exists because AppKitBackend requires special handling to resize progress spinners. + /// + /// The default implementation forwards to ``AppBackend/setSize(of:to:)``. + func setSize( + ofProgressSpinner widget: Widget, + to size: SIMD2 + ) + /// Creates a progress bar. func createProgressBar() -> Widget /// Updates a progress bar to reflect the given progress (between 0 and 1), and the @@ -1123,6 +1133,13 @@ extension AppBackend { todo() } + public func setSize( + ofProgressSpinner widget: Widget, + to size: SIMD2 + ) { + setSize(of: widget, to: size) + } + public func createProgressBar() -> Widget { todo() } diff --git a/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift b/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift index fc23e4f164..a679cb9dd4 100644 --- a/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift +++ b/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift @@ -4,7 +4,7 @@ public struct MenuItemsBuilder { public static func buildBlock() -> [MenuItem] { [] } - + public static func buildPartialBlock(first: Button) -> [MenuItem] { [.button(first)] } diff --git a/Sources/SwiftCrossUI/Layout/LayoutSystem.swift b/Sources/SwiftCrossUI/Layout/LayoutSystem.swift index e2473821bf..064fa86160 100644 --- a/Sources/SwiftCrossUI/Layout/LayoutSystem.swift +++ b/Sources/SwiftCrossUI/Layout/LayoutSystem.swift @@ -59,7 +59,8 @@ public enum LayoutSystem { var tag: String? public init( - computeLayout: @escaping @MainActor (ProposedViewSize, EnvironmentValues) -> + computeLayout: + @escaping @MainActor (ProposedViewSize, EnvironmentValues) -> ViewLayoutResult, commit: @escaping @MainActor () -> ViewLayoutResult, tag: String? = nil diff --git a/Sources/SwiftCrossUI/Views/EitherView.swift b/Sources/SwiftCrossUI/Views/EitherView.swift index 685ca68246..ec8e482780 100644 --- a/Sources/SwiftCrossUI/Views/EitherView.swift +++ b/Sources/SwiftCrossUI/Views/EitherView.swift @@ -59,7 +59,7 @@ extension EitherView: TypeSafeView { switch storage { case .a(let a): switch children.node { - case let .a(nodeA): + case .a(let nodeA): result = nodeA.computeLayout( with: a, proposedSize: proposedSize, @@ -82,7 +82,7 @@ extension EitherView: TypeSafeView { } case .b(let b): switch children.node { - case let .b(nodeB): + case .b(let nodeB): result = nodeB.computeLayout( with: b, proposedSize: proposedSize, diff --git a/Sources/SwiftCrossUI/Views/ProgressView.swift b/Sources/SwiftCrossUI/Views/ProgressView.swift index abd34ca147..55886758c0 100644 --- a/Sources/SwiftCrossUI/Views/ProgressView.swift +++ b/Sources/SwiftCrossUI/Views/ProgressView.swift @@ -4,6 +4,7 @@ public struct ProgressView: View { private var label: Label private var progress: Double? private var kind: Kind + private var isSpinnerResizable: Bool = false private enum Kind { case spinner @@ -23,7 +24,7 @@ public struct ProgressView: View { private var progressIndicator: some View { switch kind { case .spinner: - ProgressSpinnerView() + ProgressSpinnerView(isResizable: isSpinnerResizable) case .bar: ProgressBarView(value: progress) } @@ -50,6 +51,14 @@ public struct ProgressView: View { self.kind = .bar self.progress = value.map(Double.init) } + + /// Makes the ProgressView resize to fit the available space. + /// Only affects ``Kind/spinner``. + public func resizable(_ isResizable: Bool = true) -> Self { + var progressView = self + progressView.isSpinnerResizable = isResizable + return progressView + } } extension ProgressView where Label == EmptyView { @@ -101,7 +110,11 @@ extension ProgressView where Label == Text { } struct ProgressSpinnerView: ElementaryView { - init() {} + let isResizable: Bool + + init(isResizable: Bool = false) { + self.isResizable = isResizable + } func asWidget(backend: Backend) -> Backend.Widget { backend.createProgressSpinner() @@ -113,8 +126,28 @@ struct ProgressSpinnerView: ElementaryView { environment: EnvironmentValues, backend: Backend ) -> ViewLayoutResult { - let size = ViewSize(backend.naturalSize(of: widget)) - return ViewLayoutResult.leafView(size: size) + let naturalSize = backend.naturalSize(of: widget) + + guard isResizable else { + return ViewLayoutResult.leafView(size: ViewSize(naturalSize)) + } + + let minimumDimension: Double + + if let proposedWidth = proposedSize.width, + let proposedHeight = proposedSize.height + { + minimumDimension = max(min(proposedWidth, proposedHeight), 0) + } else { + minimumDimension = max( + proposedSize.width ?? proposedSize.height ?? 0, + Double(min(naturalSize.x, naturalSize.y)) + ) + } + + return ViewLayoutResult.leafView( + size: ViewSize(minimumDimension, minimumDimension) + ) } func commit( @@ -122,7 +155,12 @@ struct ProgressSpinnerView: ElementaryView { layout: ViewLayoutResult, environment: EnvironmentValues, backend: Backend - ) {} + ) { + // Doesn't change the rendered size of ProgressSpinner + // on UIKitBackend, but still sets container size to + // (width: n, height: n) n = min(proposedSize.x, proposedSize.y) + backend.setSize(ofProgressSpinner: widget, to: layout.size.vector) + } } struct ProgressBarView: ElementaryView { diff --git a/Sources/SwiftCrossUI/Views/ScrollView.swift b/Sources/SwiftCrossUI/Views/ScrollView.swift index caa14b14e9..1011e914a8 100644 --- a/Sources/SwiftCrossUI/Views/ScrollView.swift +++ b/Sources/SwiftCrossUI/Views/ScrollView.swift @@ -122,14 +122,16 @@ public struct ScrollView: TypeSafeView, View { // Compute the outer size. var outerSize = finalChildResult.size if axes.contains(.horizontal) { - outerSize.width = proposedSize.width + outerSize.width = + proposedSize.width ?? (finalChildResult.size.width + verticalScrollBarWidth) } else { outerSize.width += verticalScrollBarWidth } if axes.contains(.vertical) { - outerSize.height = proposedSize.height + outerSize.height = + proposedSize.height ?? (finalChildResult.size.height + horizontalScrollBarHeight) } else { outerSize.height += horizontalScrollBarHeight