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
Original file line number Diff line number Diff line change
@@ -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<Label: View>: 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<V: BinaryFloatingPoint>(value: V?, total: V = 1.0) {
self.init(value: value.map { Double($0 / total) }) { EmptyView() }
}
}

public extension ProgressView where Label == Text {

init<S: StringProtocol>(_ title: S) {
self.init(value: nil) { Text(title) }
}

init<S: StringProtocol, V: BinaryFloatingPoint>(_ 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<Self, _ProgressViewStyleModifier> {
modifier(_ProgressViewStyleModifier(style: style))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<V: BinaryFloatingPoint>(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 {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions Demo/App.swiftpm/Sources/ControlPlaygrounds.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
}
Expand Down
63 changes: 46 additions & 17 deletions Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
Loading