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
3 changes: 3 additions & 0 deletions Demo/App.swiftpm/Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ enum GalleryScreen: String, CaseIterable, Identifiable {
case observation
case state
case modifiers
case controls

var id: String { rawValue }

Expand All @@ -49,6 +50,7 @@ enum GalleryScreen: String, CaseIterable, Identifiable {
case .observation: return "Observation"
case .state: return "State"
case .modifiers: return "Modifiers"
case .controls: return "Controls"
}
}

Expand All @@ -64,6 +66,7 @@ enum GalleryScreen: String, CaseIterable, Identifiable {
case .observation: return AnyView(ObservationScreen())
case .state: return AnyView(StateScreen())
case .modifiers: return AnyView(ModifierScreen())
case .controls: return AnyView(ControlsScreen())
}
}
}
35 changes: 35 additions & 0 deletions Demo/App.swiftpm/Sources/ControlsScreen.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#if canImport(AndroidSwiftUI)
import AndroidSwiftUI
#else
import SwiftUI
#endif

/// Form controls and their state bindings.
struct ControlsScreen: View {

@State
private var isOn = false

@State
private var progress = 0.25

var body: some View {
ScrollView {
VStack(spacing: 24) {
Text("Toggle")
Toggle("Enabled", isOn: $isOn)
Text(isOn ? "Toggle is on" : "Toggle is off")
Divider()
Text("Indeterminate progress")
ProgressView()
Divider()
Text("Determinate progress")
ProgressView(value: progress)
Text("\(Int(progress * 100))%")
Button("Advance") {
progress = progress >= 1 ? 0 : progress + 0.25
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.pureswift.swiftandroid

import android.widget.CompoundButton

class CompoundButtonOnCheckedChangeListener(val action: SwiftObject): CompoundButton.OnCheckedChangeListener {

external override fun onCheckedChanged(button: CompoundButton, isChecked: Boolean)
}
5 changes: 3 additions & 2 deletions Demo/build-swift.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ xcrun --toolchain swift swift build -c $SWIFT_COMPILATION_MODE \
--toolchain $XCTOOLCHAIN \
--package-path $SWIFT_PACKAGE_SRC

# Copy compiled Swift package
# Copy compiled Swift package, along with the shared libraries it links against
# (`libSwiftJava.so` is loaded at runtime and the app fails to start without it)
mkdir -p $SRC_ROOT/app/src/main/jniLibs/$ANDROID_ARCH/
cp -rf $SWIFT_PACKAGE_SRC/.build/$SWIFT_TARGET_NAME/debug/libSwiftAndroidApp.so \
cp -rf $SWIFT_PACKAGE_SRC/.build/$SWIFT_TARGET_NAME/debug/*.so \
$SRC_ROOT/app/src/main/jniLibs/$ANDROID_ARCH/

# Build locally for preview
Expand Down
68 changes: 68 additions & 0 deletions Sources/AndroidSwiftUI/AndroidProgressView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//
// AndroidProgressView.swift
// AndroidSwiftUI
//
// Created by Alsey Coleman Miller on 7/22/26.
//

import AndroidKit

/// The scale determinate progress is reported on, since `ProgressBar` works in whole steps
/// while `ProgressView` reports a fraction between zero and one.
private let progressScale: Int32 = 10_000

extension _FractionalProgressView: AndroidPrimitive {

var renderedBody: AnyView {
AnyView(AndroidProgressBar(fractionCompleted: fractionCompleted))
}
}

extension _IndeterminateProgressView: AndroidPrimitive {

var renderedBody: AnyView {
AnyView(AndroidProgressBar(fractionCompleted: nil))
}
}

/// Native progress bar, shown as a horizontal track when the progress is known and as the
/// platform's indeterminate spinner otherwise.
struct AndroidProgressBar {

let fractionCompleted: Double?
}

extension AndroidProgressBar: AndroidViewRepresentable {

typealias Coordinator = Void

func makeAndroidView(context: Self.Context) -> AndroidWidget.ProgressBar {
// the horizontal track is only selectable through a style attribute, so the
// determinate and indeterminate bars are distinct widgets rather than one widget
// toggling `setIndeterminate`
let style = fractionCompleted == nil
? try! JavaClass<AndroidR.R.attr>().progressBarStyle
: try! JavaClass<AndroidR.R.attr>().progressBarStyleHorizontal
let view = AndroidWidget.ProgressBar(context.androidContext, nil, style)
view.setMax(progressScale)
updateView(view)
return view
}

func updateAndroidView(_ view: AndroidWidget.ProgressBar, context: Self.Context) {
updateView(view)
}
}

private extension AndroidProgressBar {

func updateView(_ view: AndroidWidget.ProgressBar) {
guard let fractionCompleted else {
view.setIndeterminate(true)
return
}
view.setIndeterminate(false)
let clamped = min(max(fractionCompleted, 0), 1)
view.setProgress(Int32((clamped * Double(progressScale)).rounded()))
}
}
66 changes: 66 additions & 0 deletions Sources/AndroidSwiftUI/AndroidToggle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// AndroidToggle.swift
// AndroidSwiftUI
//
// Created by Alsey Coleman Miller on 7/22/26.
//

import AndroidKit

/// Renders a toggle as the platform's Material switch.
///
/// `Toggle` reads `EnvironmentValues.toggleStyle` whether or not its body is used, and the
/// key traps unless a renderer supplies a default, so this is registered in
/// `EnvironmentValues.defaultEnvironment`.
struct DefaultToggleStyle: ToggleStyle {

typealias Body = AndroidSwitch

func makeBody(configuration: ToggleStyleConfiguration) -> AndroidSwitch {
AndroidSwitch(isOn: configuration.$isOn, label: configuration.label)
}
}

/// Material switch bound to a `Bool`, labelled by the toggle's own label.
struct AndroidSwitch {

@Binding
var isOn: Bool

let label: AnyView
}

extension AndroidSwitch: AndroidViewRepresentable {

typealias Coordinator = Void

func makeAndroidView(context: Self.Context) -> AndroidMaterial.MaterialSwitch {
let view = MaterialSwitch(context.androidContext)
let listener = CompoundButtonOnCheckedChangeListener { isChecked in
// only write back on a real change, so the binding isn't touched when the
// reconciler sets the checked state during an update
guard isChecked != self.isOn else { return }
self.isOn = isChecked
}
view.setOnCheckedChangeListener(listener.as(AndroidWidget.CompoundButton.OnCheckedChangeListener.self))
updateView(view)
return view
}

func updateAndroidView(_ view: AndroidMaterial.MaterialSwitch, context: Self.Context) {
updateView(view)
}
}

private extension AndroidSwitch {

func updateView(_ view: AndroidMaterial.MaterialSwitch) {
if view.isChecked() != isOn {
view.setChecked(isOn)
}
if let text = mapAnyView(label, transform: { (text: Text) in text }),
let textView = view.as(AndroidWidget.TextView.self) {
text.updateTextView(textView)
}
}
}
45 changes: 45 additions & 0 deletions Sources/AndroidSwiftUI/OnCheckedChangeListener.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// OnCheckedChangeListener.swift
// AndroidSwiftUI
//
// Created by Alsey Coleman Miller on 7/22/26.
//

import Foundation
import AndroidKit

@JavaClass("com.pureswift.swiftandroid.CompoundButtonOnCheckedChangeListener", extends: AndroidWidget.CompoundButton.OnCheckedChangeListener.self)
open class CompoundButtonOnCheckedChangeListener: JavaObject {

public typealias Action = (Bool) -> ()

@JavaMethod
@_nonoverride public convenience init(action: SwiftObject?, environment: JNIEnvironment? = nil)

@JavaMethod
public func getAction() -> SwiftObject?
}

@JavaImplementation("com.pureswift.swiftandroid.CompoundButtonOnCheckedChangeListener")
extension CompoundButtonOnCheckedChangeListener {

@JavaMethod
func onCheckedChanged(_ isChecked: Bool) {
// drain queue, matching `ViewOnClickListener`
RunLoop.main.run(until: Date() + 0.01)
action(isChecked)
RunLoop.main.run(until: Date() + 0.01)
}
}

public extension CompoundButtonOnCheckedChangeListener {

convenience init(action: @escaping (Bool) -> (), environment: JNIEnvironment? = nil) {
let object = SwiftObject(action, environment: environment)
self.init(action: object, environment: environment)
}

var action: ((Bool) -> ()) {
getAction()!.valueObject().value as! Action
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ internal extension EnvironmentValues {
static var defaultEnvironment: Self {
var environment = EnvironmentValues()
environment[_ColorSchemeKey.self] = .light
// control style keys trap unless the renderer supplies a default, and are read
// whenever a control is in the view tree — not only when its body is evaluated
environment[_ToggleStyleKey.self] = _AnyToggleStyle(DefaultToggleStyle())
return environment
}
}
Loading