Skip to content

Commit d56ba52

Browse files
authored
Merge pull request #54 from PureSwift/feature/progressview
Give ProgressView a label and a style
2 parents 9f5d3a4 + fd3d1df commit d56ba52

5 files changed

Lines changed: 188 additions & 41 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//
2+
// ProgressView.swift
3+
// AndroidSwiftUICore
4+
//
5+
// Progress with an optional label. Determinacy and shape are independent:
6+
// whether a value is present decides determinate vs. indeterminate, while
7+
// `progressViewStyle` decides linear vs. circular. The default infers a shape
8+
// from determinacy (linear when a value is known, circular when not), which is
9+
// what the original value-only implementation always did.
10+
//
11+
12+
public struct ProgressView<Label: View>: View {
13+
14+
internal let value: Double?
15+
internal let label: Label
16+
17+
public init(value: Double? = nil, @ViewBuilder label: () -> Label) {
18+
self.value = value
19+
self.label = label()
20+
}
21+
22+
public typealias Body = Never
23+
}
24+
25+
public extension ProgressView where Label == EmptyView {
26+
27+
init() {
28+
self.init(value: nil) { EmptyView() }
29+
}
30+
31+
init<V: BinaryFloatingPoint>(value: V?, total: V = 1.0) {
32+
self.init(value: value.map { Double($0 / total) }) { EmptyView() }
33+
}
34+
}
35+
36+
public extension ProgressView where Label == Text {
37+
38+
init<S: StringProtocol>(_ title: S) {
39+
self.init(value: nil) { Text(title) }
40+
}
41+
42+
init<S: StringProtocol, V: BinaryFloatingPoint>(_ title: S, value: V?, total: V = 1.0) {
43+
self.init(value: value.map { Double($0 / total) }) { Text(title) }
44+
}
45+
}
46+
47+
extension ProgressView: PrimitiveView {
48+
public func _render(in context: ResolveContext) -> RenderNode {
49+
var props: [String: PropValue] = [:]
50+
if let value { props["value"] = .double(value) }
51+
return RenderNode(
52+
type: "ProgressView",
53+
id: context.path,
54+
props: props,
55+
children: Evaluator.resolveChildren(label, context.descending("label"))
56+
)
57+
}
58+
}
59+
60+
// MARK: - Style
61+
62+
/// The built-in progress shapes. This is the `.circular` / `.linear` spelling
63+
/// only — the `ProgressViewStyle` protocol for custom styles isn't modeled.
64+
public struct _ProgressViewStyle: Sendable {
65+
66+
internal let kind: String
67+
68+
public static let automatic = _ProgressViewStyle(kind: "automatic")
69+
public static let linear = _ProgressViewStyle(kind: "linear")
70+
public static let circular = _ProgressViewStyle(kind: "circular")
71+
}
72+
73+
public struct _ProgressViewStyleModifier: RenderModifier {
74+
let style: _ProgressViewStyle
75+
public var _modifierNode: ModifierNode {
76+
ModifierNode(kind: "progressViewStyle", args: ["style": .string(style.kind)])
77+
}
78+
}
79+
80+
public extension View {
81+
func progressViewStyle(_ style: _ProgressViewStyle) -> ModifiedContent<Self, _ProgressViewStyleModifier> {
82+
modifier(_ProgressViewStyleModifier(style: style))
83+
}
84+
}

AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -82,30 +82,6 @@ extension Image: PrimitiveView {
8282
}
8383
}
8484

85-
/// Progress: indeterminate (spinner) or fractional (bar).
86-
public struct ProgressView: View {
87-
88-
internal let value: Double?
89-
90-
public init() {
91-
self.value = nil
92-
}
93-
94-
public init<V: BinaryFloatingPoint>(value: V?, total: V = 1.0) {
95-
self.value = value.map { Double($0 / total) }
96-
}
97-
98-
public typealias Body = Never
99-
}
100-
101-
extension ProgressView: PrimitiveView {
102-
public func _render(in context: ResolveContext) -> RenderNode {
103-
var props: [String: PropValue] = [:]
104-
if let value { props["value"] = .double(value) }
105-
return RenderNode(type: "ProgressView", id: context.path, props: props)
106-
}
107-
}
108-
10985
/// A control for selecting a value from a bounded range.
11086
public struct Slider: View {
11187

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,48 @@ struct ControlTests {
106106
#expect(isFocused(node.children[1]) == true)
107107
}
108108

109+
@Test("ProgressView keeps determinacy and shape independent")
110+
func progressViewStyle() {
111+
// no value, no style: indeterminate, and the interpreter picks circular
112+
let plain = ViewHost(ProgressView()).evaluate()
113+
#expect(plain.props["value"] == nil)
114+
#expect(plain.modifiers.first { $0.kind == "progressViewStyle" } == nil)
115+
116+
// a value is normalized against total
117+
let valued = ViewHost(ProgressView(value: 25.0, total: 100.0)).evaluate()
118+
#expect(valued.props["value"] == .double(0.25))
119+
120+
// a determinate circular bar — impossible before, since shape was
121+
// inferred from determinacy
122+
let circular = ViewHost(ProgressView(value: 0.5).progressViewStyle(.circular)).evaluate()
123+
#expect(circular.props["value"] == .double(0.5))
124+
#expect(circular.modifiers.first { $0.kind == "progressViewStyle" }?.args["style"] == .string("circular"))
125+
126+
// and an indeterminate linear one
127+
let linear = ViewHost(ProgressView().progressViewStyle(.linear)).evaluate()
128+
#expect(linear.props["value"] == nil)
129+
#expect(linear.modifiers.first { $0.kind == "progressViewStyle" }?.args["style"] == .string("linear"))
130+
}
131+
132+
@Test("ProgressView carries a label when given one")
133+
func progressViewLabel() {
134+
let titled = ViewHost(ProgressView("Loading")).evaluate()
135+
#expect(firstTextString(titled) == "Loading")
136+
#expect(titled.props["value"] == nil)
137+
138+
let both = ViewHost(ProgressView("Copying", value: 0.4)).evaluate()
139+
#expect(firstTextString(both) == "Copying")
140+
#expect(both.props["value"] == .double(0.4))
141+
142+
// the builder form takes any view
143+
let built = ViewHost(ProgressView(value: 0.1) { Text("Custom") }).evaluate()
144+
#expect(firstTextString(built) == "Custom")
145+
146+
// and the label-free forms stay label-free
147+
#expect(ViewHost(ProgressView()).evaluate().children.isEmpty)
148+
#expect(ViewHost(ProgressView(value: 0.5)).evaluate().children.isEmpty)
149+
}
150+
109151
@Test("Picker emits tagged children and maps the selection string back")
110152
func picker() {
111153
struct Screen: View {

Demo/App.swiftpm/Sources/ControlPlaygrounds.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,22 @@ struct ProgressViewPlayground: View {
193193
Button("Advance") { progress = progress >= 1 ? 0 : progress + 0.25 }
194194
}
195195
}
196+
Example("With a label") {
197+
VStack(alignment: .leading, spacing: 12) {
198+
ProgressView("Loading…")
199+
ProgressView("Copying files", value: progress)
200+
}
201+
}
202+
Example("progressViewStyle") {
203+
VStack(alignment: .leading, spacing: 12) {
204+
// determinate, but drawn as a ring
205+
ProgressView(value: progress)
206+
.progressViewStyle(.circular)
207+
// indeterminate, but drawn as a bar
208+
ProgressView()
209+
.progressViewStyle(.linear)
210+
}
211+
}
196212
}
197213
}
198214
}

Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -359,22 +359,7 @@ private fun RenderResolved(node: ViewNode) {
359359

360360
"VideoPlayer" -> RenderVideoPlayer(node)
361361

362-
"ProgressView" -> {
363-
val value = node.double("value")
364-
val tint = LocalTint.current
365-
if (value != null) {
366-
LinearProgressIndicator(
367-
progress = { value.toFloat() },
368-
color = tint ?: ProgressIndicatorDefaults.linearColor,
369-
modifier = node.composeModifiers().fillMaxWidth(),
370-
)
371-
} else {
372-
CircularProgressIndicator(
373-
color = tint ?: ProgressIndicatorDefaults.circularColor,
374-
modifier = node.composeModifiers(),
375-
)
376-
}
377-
}
362+
"ProgressView" -> RenderProgressView(node)
378363

379364
"Slider" -> {
380365
val onChange = node.long("onChange")
@@ -435,6 +420,50 @@ private fun RenderResolved(node: ViewNode) {
435420

436421
// Sheets and alerts ride as hidden children; the presentation layer shows
437422
// them, so the normal child loop skips them.
423+
// Determinacy (is there a value?) and shape (progressViewStyle) are separate
424+
// axes; the default picks a shape from determinacy, matching the original
425+
// value-only behavior. A label, when present, sits beneath the indicator.
426+
@Composable
427+
private fun RenderProgressView(node: ViewNode) {
428+
val value = node.double("value")
429+
val tint = LocalTint.current
430+
val style = node.modifiers.firstOrNull { it.kind == "progressViewStyle" }?.args?.string("style")
431+
val circular = when (style) {
432+
"circular" -> true
433+
"linear" -> false
434+
else -> value == null // automatic
435+
}
436+
val label = node.children.filter { !it.isPresentation() }
437+
438+
Column(
439+
horizontalAlignment = Alignment.CenterHorizontally,
440+
modifier = node.composeModifiers(),
441+
) {
442+
when {
443+
circular && value != null -> CircularProgressIndicator(
444+
progress = { value.toFloat() },
445+
color = tint ?: ProgressIndicatorDefaults.circularColor,
446+
)
447+
circular -> CircularProgressIndicator(
448+
color = tint ?: ProgressIndicatorDefaults.circularColor,
449+
)
450+
value != null -> LinearProgressIndicator(
451+
progress = { value.toFloat() },
452+
color = tint ?: ProgressIndicatorDefaults.linearColor,
453+
modifier = Modifier.fillMaxWidth(),
454+
)
455+
else -> LinearProgressIndicator(
456+
color = tint ?: ProgressIndicatorDefaults.linearColor,
457+
modifier = Modifier.fillMaxWidth(),
458+
)
459+
}
460+
if (label.isNotEmpty()) {
461+
Spacer(modifier = Modifier.height(6.dp))
462+
label.forEach { RenderChild(it) }
463+
}
464+
}
465+
}
466+
438467
private fun ViewNode.isPresentation(): Boolean =
439468
type == "Sheet" || type == "Alert" || type == "ConfirmationDialog" || type == "ToolbarItem"
440469

@@ -1285,7 +1314,7 @@ private val KNOWN_MODIFIER_KINDS = setOf(
12851314
"scale", "opacity", "border", "shadow", "clipShape", "onTapGesture", "disabled",
12861315
"font", "fontWeight", "italic", "foregroundColor", "lineLimit", "multilineTextAlignment",
12871316
"tint", "onAppear", "onDisappear", "task", "onChange", "animation", "tag", "tabItem",
1288-
"transition", "focused", "longPress", "drag", "contentMode",
1317+
"transition", "focused", "longPress", "drag", "contentMode", "progressViewStyle",
12891318
)
12901319

12911320
// Folds a frame entry: fixed size, fill (maxWidth/Height .infinity), bounded

0 commit comments

Comments
 (0)