diff --git a/Examples/Sources/ControlsExample/ControlsApp.swift b/Examples/Sources/ControlsExample/ControlsApp.swift index fe94e13ee8..5f782cdb23 100644 --- a/Examples/Sources/ControlsExample/ControlsApp.swift +++ b/Examples/Sources/ControlsExample/ControlsApp.swift @@ -16,71 +16,86 @@ 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) + Button("Randomize progress view size") { + progressViewSize = Int.random(in: 10...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 f0f3fa4817..efce0e934a 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -484,6 +484,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), @@ -1181,11 +1190,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 setProgressSpinnerSize( + _ widget: Widget, + _ 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 31a412cf2f..296e857837 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -541,6 +541,15 @@ public protocol AppBackend: Sendable { /// Creates an indeterminate progress spinner. func createProgressSpinner() -> Widget + /// Changes the Spinner's Size. + /// Required due to AppKitBackend needing special treatment. + /// Forward to ``AppBackend/setSize(of widget: Widget, to size: SIMD2)`` + /// on other Backends. + func setProgressSpinnerSize( + _ widget: Widget, + _ size: SIMD2 + ) + /// Creates a progress bar. func createProgressBar() -> Widget /// Updates a progress bar to reflect the given progress (between 0 and 1), and the @@ -1028,6 +1037,13 @@ extension AppBackend { todo() } + public func setProgressSpinnerSize( + _ widget: Widget, + _ size: SIMD2 + ) { + setSize(of: widget, to: size) + } + public func createProgressBar() -> Widget { todo() } diff --git a/Sources/SwiftCrossUI/Views/ProgressView.swift b/Sources/SwiftCrossUI/Views/ProgressView.swift index 1653410ba6..cc27097ecc 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() @@ -114,8 +127,33 @@ struct ProgressSpinnerView: ElementaryView { backend: Backend, dryRun: Bool ) -> ViewUpdateResult { - ViewUpdateResult.leafView( - size: ViewSize(fixedSize: backend.naturalSize(of: widget)) + let naturalSize = backend.naturalSize(of: widget) + guard isResizable else { + // Required to reset its size when resizability + // gets changed at runtime + backend.setProgressSpinnerSize(widget, naturalSize) + return ViewUpdateResult.leafView(size: ViewSize(fixedSize: naturalSize)) + } + let minimumDimension = max(min(proposedSize.x, proposedSize.y), 0) + let size = SIMD2( + minimumDimension, + minimumDimension + ) + if !dryRun { + // 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.setProgressSpinnerSize(widget, size) + } + return ViewUpdateResult.leafView( + size: ViewSize( + size: size, + idealSize: naturalSize, + minimumWidth: 0, + minimumHeight: 0, + maximumWidth: nil, + maximumHeight: nil + ) ) } }