From 0e15ff4e65c3be026e39f76a65baa30ed63c0412 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 20:42:59 -0400 Subject: [PATCH 1/5] Move ProgressView out of the shared view file --- .../AndroidSwiftUICore/Primitives/Views.swift | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift index ccf4916..9749280 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift @@ -82,30 +82,6 @@ extension Image: PrimitiveView { } } -/// Progress: indeterminate (spinner) or fractional (bar). -public struct ProgressView: View { - - internal let value: Double? - - public init() { - self.value = nil - } - - public init(value: V?, total: V = 1.0) { - self.value = value.map { Double($0 / total) } - } - - public typealias Body = Never -} - -extension ProgressView: PrimitiveView { - public func _render(in context: ResolveContext) -> RenderNode { - var props: [String: PropValue] = [:] - if let value { props["value"] = .double(value) } - return RenderNode(type: "ProgressView", id: context.path, props: props) - } -} - /// A control for selecting a value from a bounded range. public struct Slider: View { From b0114cabacde71af01a20c0835dd88488786eb30 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 20:42:59 -0400 Subject: [PATCH 2/5] Give ProgressView a label and a style --- .../Primitives/ProgressView.swift | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/ProgressView.swift diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/ProgressView.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/ProgressView.swift new file mode 100644 index 0000000..c751f12 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/ProgressView.swift @@ -0,0 +1,84 @@ +// +// ProgressView.swift +// AndroidSwiftUICore +// +// Progress with an optional label. Determinacy and shape are independent: +// whether a value is present decides determinate vs. indeterminate, while +// `progressViewStyle` decides linear vs. circular. The default infers a shape +// from determinacy (linear when a value is known, circular when not), which is +// what the original value-only implementation always did. +// + +public struct ProgressView: View { + + internal let value: Double? + internal let label: Label + + public init(value: Double? = nil, @ViewBuilder label: () -> Label) { + self.value = value + self.label = label() + } + + public typealias Body = Never +} + +public extension ProgressView where Label == EmptyView { + + init() { + self.init(value: nil) { EmptyView() } + } + + init(value: V?, total: V = 1.0) { + self.init(value: value.map { Double($0 / total) }) { EmptyView() } + } +} + +public extension ProgressView where Label == Text { + + init(_ title: S) { + self.init(value: nil) { Text(title) } + } + + init(_ title: S, value: V?, total: V = 1.0) { + self.init(value: value.map { Double($0 / total) }) { Text(title) } + } +} + +extension ProgressView: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + var props: [String: PropValue] = [:] + if let value { props["value"] = .double(value) } + return RenderNode( + type: "ProgressView", + id: context.path, + props: props, + children: Evaluator.resolveChildren(label, context.descending("label")) + ) + } +} + +// MARK: - Style + +/// The built-in progress shapes. This is the `.circular` / `.linear` spelling +/// only — the `ProgressViewStyle` protocol for custom styles isn't modeled. +public struct _ProgressViewStyle: Sendable { + + internal let kind: String + + public static let automatic = _ProgressViewStyle(kind: "automatic") + public static let linear = _ProgressViewStyle(kind: "linear") + public static let circular = _ProgressViewStyle(kind: "circular") +} + +public struct _ProgressViewStyleModifier: RenderModifier { + let style: _ProgressViewStyle + public var _modifierNode: ModifierNode { + ModifierNode(kind: "progressViewStyle", args: ["style": .string(style.kind)]) + } +} + +public extension View { + func progressViewStyle(_ style: _ProgressViewStyle) -> ModifiedContent { + modifier(_ProgressViewStyleModifier(style: style)) + } +} From 34c618b82418665fb612d806c8b8fb3d8d1550f9 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 20:42:59 -0400 Subject: [PATCH 3/5] Draw progress by style and show its label --- .../kotlin/com/pureswift/swiftui/Render.kt | 63 ++++++++++++++----- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt index 3a64104..2ac0918 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -359,22 +359,7 @@ private fun RenderResolved(node: ViewNode) { "VideoPlayer" -> RenderVideoPlayer(node) - "ProgressView" -> { - val value = node.double("value") - val tint = LocalTint.current - if (value != null) { - LinearProgressIndicator( - progress = { value.toFloat() }, - color = tint ?: ProgressIndicatorDefaults.linearColor, - modifier = node.composeModifiers().fillMaxWidth(), - ) - } else { - CircularProgressIndicator( - color = tint ?: ProgressIndicatorDefaults.circularColor, - modifier = node.composeModifiers(), - ) - } - } + "ProgressView" -> RenderProgressView(node) "Slider" -> { val onChange = node.long("onChange") @@ -435,6 +420,50 @@ private fun RenderResolved(node: ViewNode) { // Sheets and alerts ride as hidden children; the presentation layer shows // them, so the normal child loop skips them. +// Determinacy (is there a value?) and shape (progressViewStyle) are separate +// axes; the default picks a shape from determinacy, matching the original +// value-only behavior. A label, when present, sits beneath the indicator. +@Composable +private fun RenderProgressView(node: ViewNode) { + val value = node.double("value") + val tint = LocalTint.current + val style = node.modifiers.firstOrNull { it.kind == "progressViewStyle" }?.args?.string("style") + val circular = when (style) { + "circular" -> true + "linear" -> false + else -> value == null // automatic + } + val label = node.children.filter { !it.isPresentation() } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = node.composeModifiers(), + ) { + when { + circular && value != null -> CircularProgressIndicator( + progress = { value.toFloat() }, + color = tint ?: ProgressIndicatorDefaults.circularColor, + ) + circular -> CircularProgressIndicator( + color = tint ?: ProgressIndicatorDefaults.circularColor, + ) + value != null -> LinearProgressIndicator( + progress = { value.toFloat() }, + color = tint ?: ProgressIndicatorDefaults.linearColor, + modifier = Modifier.fillMaxWidth(), + ) + else -> LinearProgressIndicator( + color = tint ?: ProgressIndicatorDefaults.linearColor, + modifier = Modifier.fillMaxWidth(), + ) + } + if (label.isNotEmpty()) { + Spacer(modifier = Modifier.height(6.dp)) + label.forEach { RenderChild(it) } + } + } +} + private fun ViewNode.isPresentation(): Boolean = type == "Sheet" || type == "Alert" || type == "ConfirmationDialog" || type == "ToolbarItem" @@ -1285,7 +1314,7 @@ private val KNOWN_MODIFIER_KINDS = setOf( "scale", "opacity", "border", "shadow", "clipShape", "onTapGesture", "disabled", "font", "fontWeight", "italic", "foregroundColor", "lineLimit", "multilineTextAlignment", "tint", "onAppear", "onDisappear", "task", "onChange", "animation", "tag", "tabItem", - "transition", "focused", "longPress", "drag", "contentMode", + "transition", "focused", "longPress", "drag", "contentMode", "progressViewStyle", ) // Folds a frame entry: fixed size, fill (maxWidth/Height .infinity), bounded From 2ab91c9db7c9129b1ecf7c2ea14564d90c76d810 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 20:42:59 -0400 Subject: [PATCH 4/5] Test progress labels and styles --- .../ControlTests.swift | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift index 448e197..4e99923 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift @@ -106,6 +106,48 @@ struct ControlTests { #expect(isFocused(node.children[1]) == true) } + @Test("ProgressView keeps determinacy and shape independent") + func progressViewStyle() { + // no value, no style: indeterminate, and the interpreter picks circular + let plain = ViewHost(ProgressView()).evaluate() + #expect(plain.props["value"] == nil) + #expect(plain.modifiers.first { $0.kind == "progressViewStyle" } == nil) + + // a value is normalized against total + let valued = ViewHost(ProgressView(value: 25.0, total: 100.0)).evaluate() + #expect(valued.props["value"] == .double(0.25)) + + // a determinate circular bar — impossible before, since shape was + // inferred from determinacy + let circular = ViewHost(ProgressView(value: 0.5).progressViewStyle(.circular)).evaluate() + #expect(circular.props["value"] == .double(0.5)) + #expect(circular.modifiers.first { $0.kind == "progressViewStyle" }?.args["style"] == .string("circular")) + + // and an indeterminate linear one + let linear = ViewHost(ProgressView().progressViewStyle(.linear)).evaluate() + #expect(linear.props["value"] == nil) + #expect(linear.modifiers.first { $0.kind == "progressViewStyle" }?.args["style"] == .string("linear")) + } + + @Test("ProgressView carries a label when given one") + func progressViewLabel() { + let titled = ViewHost(ProgressView("Loading")).evaluate() + #expect(firstTextString(titled) == "Loading") + #expect(titled.props["value"] == nil) + + let both = ViewHost(ProgressView("Copying", value: 0.4)).evaluate() + #expect(firstTextString(both) == "Copying") + #expect(both.props["value"] == .double(0.4)) + + // the builder form takes any view + let built = ViewHost(ProgressView(value: 0.1) { Text("Custom") }).evaluate() + #expect(firstTextString(built) == "Custom") + + // and the label-free forms stay label-free + #expect(ViewHost(ProgressView()).evaluate().children.isEmpty) + #expect(ViewHost(ProgressView(value: 0.5)).evaluate().children.isEmpty) + } + @Test("Picker emits tagged children and maps the selection string back") func picker() { struct Screen: View { From fd3d1dfc52b8a4029d9163ba6e0b0a8f0d026b63 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 20:43:00 -0400 Subject: [PATCH 5/5] Show progress labels and styles in the playground --- .../App.swiftpm/Sources/ControlPlaygrounds.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Demo/App.swiftpm/Sources/ControlPlaygrounds.swift b/Demo/App.swiftpm/Sources/ControlPlaygrounds.swift index 7057779..8c2e5fb 100644 --- a/Demo/App.swiftpm/Sources/ControlPlaygrounds.swift +++ b/Demo/App.swiftpm/Sources/ControlPlaygrounds.swift @@ -193,6 +193,22 @@ struct ProgressViewPlayground: View { Button("Advance") { progress = progress >= 1 ? 0 : progress + 0.25 } } } + Example("With a label") { + VStack(alignment: .leading, spacing: 12) { + ProgressView("Loading…") + ProgressView("Copying files", value: progress) + } + } + Example("progressViewStyle") { + VStack(alignment: .leading, spacing: 12) { + // determinate, but drawn as a ring + ProgressView(value: progress) + .progressViewStyle(.circular) + // indeterminate, but drawn as a bar + ProgressView() + .progressViewStyle(.linear) + } + } } } }