From f296b34bfa873cc0a00c38c35e84b30ed04a5895 Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Wed, 1 Apr 2026 17:09:00 -0700 Subject: [PATCH 1/2] Use androidx-material3 1.5.0-alpha16 --- Sources/SkipUI/Skip/skip.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/SkipUI/Skip/skip.yml b/Sources/SkipUI/Skip/skip.yml index b47c1a60..4e06d1f1 100644 --- a/Sources/SkipUI/Skip/skip.yml +++ b/Sources/SkipUI/Skip/skip.yml @@ -17,6 +17,7 @@ settings: - 'version("androidx-appcompat", "1.7.1")' - 'version("androidx-activity", "1.13.0")' - 'version("androidx-lifecycle-process", "2.10.0")' + - 'version("androidx-material3", "1.5.0-alpha16")' - 'version("androidx-material3-adaptive", "1.2.0")' - 'version("androidx-work", "2.11.1")' @@ -26,7 +27,7 @@ settings: - 'library("androidx-compose-ui-tooling", "androidx.compose.ui", "ui-tooling").withoutVersion()' - 'library("androidx-compose-animation", "androidx.compose.animation", "animation").withoutVersion()' - 'library("androidx-compose-material", "androidx.compose.material", "material").withoutVersion()' - - 'library("androidx-compose-material3", "androidx.compose.material3", "material3").withoutVersion()' + - 'library("androidx-compose-material3", "androidx.compose.material3", "material3").versionRef("androidx-material3")' - 'library("androidx-compose-material-icons-extended", "androidx.compose.material", "material-icons-extended").withoutVersion()' - 'library("androidx-compose-foundation", "androidx.compose.foundation", "foundation").withoutVersion()' - 'library("androidx-appcompat", "androidx.appcompat", "appcompat").versionRef("androidx-appcompat")' From 0ee598045875d536a93429f535619c2eba4f111d Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Wed, 1 Apr 2026 17:58:13 -0700 Subject: [PATCH 2/2] Add support for M3 Expressive progress indicators Fixes #379 --- README.md | 55 ++++++++++ .../SkipUI/Components/ProgressView.swift | 101 ++++++++++++++++-- .../Environment/EnvironmentValues.swift | 5 + 3 files changed, 154 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 31af9ab9..6b488cec 100644 --- a/README.md +++ b/README.md @@ -563,6 +563,61 @@ struct MyContentModifier : ContentModifier { } ``` +### Material 3 wavy progress indicators + +On Android, `ProgressView` normally uses Material 3 `LoadingIndicator`, `LinearProgressIndicator`, and `CircularProgressIndicator`. To use the expressive wavy indicators ([`LinearWavyProgressIndicator`](https://developer.android.com/reference/kotlin/androidx/compose/material3/LinearWavyProgressIndicator), [`CircularWavyProgressIndicator`](https://developer.android.com/reference/kotlin/androidx/compose/material3/CircularWavyProgressIndicator.composable)), apply the `.material3WavyProgress()` modifier. + +```swift +extension View { + public func material3WavyProgress(wavy: Bool = true, amplitude: ((Double?) -> Double)? = nil, wavelength: Double? = nil, waveSpeed: Double? = nil) -> any View + /// Convenience overload: constant amplitude for every progress value (same as `{ _ in amplitude }`). + public func material3WavyProgress(wavy: Bool = true, amplitude fixedAmplitude: Double, wavelength: Double? = nil, waveSpeed: Double? = nil) -> any View +} + +public struct Material3WavyProgressConfiguration: Equatable { + public var isEnabled: Bool + public var amplitude: ((Double?) -> Double)? + public var wavelength: Double? + public var waveSpeed: Double? +} +``` + +In Skip Fuse, use it like this: + +```swift +ProgressView(value: 0.4) + #if os(Android) + .composeModifier { Material3WavyProgressModifier() } + #endif + +... + +#if SKIP +struct Material3WavyProgressModifier: ContentModifier { + func modify(view: any View) -> any View { + view.material3WavyProgress() + } +} +#endif +``` + +In Skip Lite, use it like this: + +```swift +ProgressView(value: 0.4) + #if SKIP + .material3WavyProgress() + #endif +``` + +Values are stored in the SwiftUI environment and affect descendant `ProgressView` instances, similar to the other `.material3` modifiers above, so you can use `.material3WavyProgress()` at the top level of your app and make all of your progress indicators wavy. + +You can configure the amplitude, wavelength, and wavespeed of wavy progress indicators. + +* The default amplitude is 1.0. 0.0 represents no amplitude, and 1.0 represents an amplitude that will take the full height of the progress indicator. You can pass a closure to set the amplitude based on the current measured progress from 0.0 to 1.0 (`nil` for indeterminate progress). +* The default wavelength comes from [`WavyProgressIndicatorDefaults`](https://developer.android.com/reference/kotlin/androidx/compose/material3/WavyProgressIndicatorDefaults). +* The default wavespeed (measured in DP per second) is equal to the wavelength, rendering an animation that moves the wave at one wavelength per second. + ### Material Effects Compose applies an automatic "ripple" effect to components on tap. You can customize the color and alpha of this effect with the `material3Ripple` modifier. To disable the effect altogether, return `nil` from your modifier closure. diff --git a/Sources/SkipUI/SkipUI/Components/ProgressView.swift b/Sources/SkipUI/SkipUI/Components/ProgressView.swift index 8ecb3601..6354ea09 100644 --- a/Sources/SkipUI/SkipUI/Components/ProgressView.swift +++ b/Sources/SkipUI/SkipUI/Components/ProgressView.swift @@ -9,13 +9,59 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.LoadingIndicatorDefaults import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.WavyProgressIndicatorDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp #endif +#if SKIP +/// Configuration for Material 3 expressive wavy progress indicators on Android. +/// +/// For ``amplitude``, pass `nil` to use a default of `1.0`. +/// The closure receives `nil` for indeterminate indicators; for determinate indicators it receives the current fraction in `0...1`. +/// Material’s wavy indicators coerce amplitude into the valid range. +public struct Material3WavyProgressConfiguration: Equatable { + public var isEnabled: Bool + public var amplitude: ((Double?) -> Double)? + public var wavelength: Double? + public var waveSpeed: Double? + + public init(isEnabled: Bool, amplitude: ((Double?) -> Double)? = nil, wavelength: Double? = nil, waveSpeed: Double? = nil) { + self.isEnabled = isEnabled + self.amplitude = amplitude + self.wavelength = wavelength + self.waveSpeed = waveSpeed + } + + public var indeterminateAmplitude: Float { + Float(amplitude?(nil) ?? 1.0) + } + + public var amplitudeForProgress: (Float) -> Float { + { fraction in Float(amplitude?(Double(fraction)) ?? 1.0) } + } + + public static func == (lhs: Material3WavyProgressConfiguration, rhs: Material3WavyProgressConfiguration) -> Bool { + guard lhs.isEnabled == rhs.isEnabled, lhs.wavelength == rhs.wavelength, lhs.waveSpeed == rhs.waveSpeed else { + return false + } + // Cannot compare closure values; treat as equal only when both are absent. + switch (lhs.amplitude, rhs.amplitude) { + case (nil, nil): return true + default: return false + } + } +} +#endif + +// SKIP INSERT: @OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) // SKIP @bridge public struct ProgressView : View, Renderable { let value: Double? @@ -111,25 +157,54 @@ public struct ProgressView : View, Renderable { @Composable private func RenderLinearProgress(context: ComposeContext) { let modifier = Modifier.fillWidth().then(context.modifier) let color = EnvironmentValues.shared._tint?.colorImpl() ?? ProgressIndicatorDefaults.linearColor - if value == nil || total == nil { - LinearProgressIndicator(modifier: modifier, color: color) + let material3WavyProgressConfiguration = EnvironmentValues.shared._material3WavyProgress + if let wavyConfiguration = material3WavyProgressConfiguration, wavyConfiguration.isEnabled { + if let value, let total { + material3LinearWavyDeterminate(modifier: modifier, color: color, wavyConfiguration: wavyConfiguration, progress: { Float(value / total) }) + } else { + material3LinearWavyIndeterminate(modifier: modifier, color: color, wavyConfiguration: wavyConfiguration) + } + } else if let value, let total { + LinearProgressIndicator(progress: { Float(value / total) }, modifier: modifier, color: color) } else { - LinearProgressIndicator(progress: { Float(value! / total!) }, modifier: modifier, color: color) + LinearProgressIndicator(modifier: modifier, color: color) } } + @Composable private func material3LinearWavyIndeterminate(modifier: Modifier, color: androidx.compose.ui.graphics.Color, wavyConfiguration: Material3WavyProgressConfiguration) { + let wavelength = wavyConfiguration.wavelength.map { Float($0).dp } ?? WavyProgressIndicatorDefaults.LinearIndeterminateWavelength + let waveSpeed = wavyConfiguration.waveSpeed.map { Float($0).dp } ?? wavelength + LinearWavyProgressIndicator(modifier: modifier, color: color, amplitude: wavyConfiguration.indeterminateAmplitude, wavelength: wavelength, waveSpeed: waveSpeed) + } + + @Composable private func material3LinearWavyDeterminate(modifier: Modifier, color: androidx.compose.ui.graphics.Color, wavyConfiguration: Material3WavyProgressConfiguration, progress: () -> Float) { + let wavelength = wavyConfiguration.wavelength.map { Float($0).dp } ?? WavyProgressIndicatorDefaults.LinearDeterminateWavelength + let waveSpeed = wavyConfiguration.waveSpeed.map { Float($0).dp } ?? wavelength + LinearWavyProgressIndicator(progress: progress, modifier: modifier, color: color, amplitude: wavyConfiguration.amplitudeForProgress, wavelength: wavelength, waveSpeed: waveSpeed) + } + @Composable private func RenderCircularProgress(context: ComposeContext) { let color = EnvironmentValues.shared._tint?.colorImpl() ?? ProgressIndicatorDefaults.circularColor // Reduce size to better match SwiftUI - let indicatorModifier = Modifier.size(20.dp) + let indicatorModifier = context.modifier.size(20.dp) + let material3WavyProgressConfiguration = EnvironmentValues.shared._material3WavyProgress Box(modifier: context.modifier, contentAlignment: androidx.compose.ui.Alignment.Center) { - if value == nil || total == nil { - CircularProgressIndicator(modifier: indicatorModifier, color: color) + if let wavyConfiguration = material3WavyProgressConfiguration, wavyConfiguration.isEnabled { + let wavelength = wavyConfiguration.wavelength.map { Float($0).dp } ?? WavyProgressIndicatorDefaults.CircularWavelength + let waveSpeed = wavyConfiguration.waveSpeed.map { Float($0).dp } ?? wavelength + if let value, let total { + CircularWavyProgressIndicator(progress: { Float(value / total) }, modifier: indicatorModifier, color: color, amplitude: wavyConfiguration.amplitudeForProgress, wavelength: wavelength, waveSpeed: waveSpeed) + } else { + CircularWavyProgressIndicator(modifier: indicatorModifier, color: color, amplitude: wavyConfiguration.indeterminateAmplitude, wavelength: wavelength, waveSpeed: waveSpeed) + } + } else if let value, let total { + CircularProgressIndicator(progress: { Float(value / total) }, modifier: indicatorModifier, color: color) } else { - CircularProgressIndicator(progress: { Float(value! / total!) }, modifier: indicatorModifier, color: color) + CircularProgressIndicator(modifier: indicatorModifier, color: color) } } } + #else public var body: some View { stubView() @@ -158,6 +233,18 @@ extension View { #endif } + #if SKIP + public func material3WavyProgress(wavy: Bool = true, amplitude: ((Double?) -> Double)? = nil, wavelength: Double? = nil, waveSpeed: Double? = nil) -> any View { + let wavyProgressConfiguration = Material3WavyProgressConfiguration(isEnabled: wavy, amplitude: amplitude, wavelength: wavelength, waveSpeed: waveSpeed) + return environment(\._material3WavyProgress, wavyProgressConfiguration, affectsEvaluate: false) + } + + /// Convenience overload: constant amplitude for every progress value (same as `{ _ in fixedAmplitude }`). + public func material3WavyProgress(wavy: Bool = true, amplitude fixedAmplitude: Double, wavelength: Double? = nil, waveSpeed: Double? = nil) -> any View { + material3WavyProgress(wavy: wavy, amplitude: { _ in fixedAmplitude }, wavelength: wavelength, waveSpeed: waveSpeed) + } + #endif + // SKIP @bridge public func progressViewStyle(bridgedStyle: Int) -> any View { return progressViewStyle(ProgressViewStyle(rawValue: bridgedStyle)) diff --git a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift index b5e758f4..02061c22 100644 --- a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift +++ b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift @@ -805,6 +805,11 @@ extension EnvironmentValues { set { setBuiltinValue(key: "_progressViewStyle", value: newValue, defaultValue: { nil }) } } + var _material3WavyProgress: Material3WavyProgressConfiguration? { + get { builtinValue(key: "_material3WavyProgress", defaultValue: { nil }) as! Material3WavyProgressConfiguration? } + set { setBuiltinValue(key: "_material3WavyProgress", value: newValue, defaultValue: { nil }) } + } + var _tabViewStyle: TabViewStyle? { get { builtinValue(key: "_tabViewStyle", defaultValue: { nil }) as! TabViewStyle? } set { setBuiltinValue(key: "_tabViewStyle", value: newValue, defaultValue: { nil }) }