diff --git a/README.md b/README.md index f578c4bc..06dd3c7a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@ SkipUI vends the `skip.ui` Kotlin package. It is a reimplementation of SwiftUI f ![SkipUI Diagram](https://assets.skip.tools/diagrams/skip-diagrams-ui.svg) {: .diagram-vector } - ## Dependencies SkipUI depends on the [skip](https://source.skip.tools/skip) transpiler plugin. The transpiler must transpile SkipUI's own source code, and SkipUI relies on the transpiler's transformation of SwiftUI code. See [Implementation Strategy](#implementation-strategy) for details. SkipUI also depends on the [SkipFoundation](https://github.com/skiptools/skip-foundation) and [SkipModel](https://github.com/skiptools/skip-model) packages. @@ -24,178 +23,12 @@ The module is transparently adopted through the translation of `import SwiftUI` ## Status -SkipUI - together with the Skip transpiler - has robust support for the building blocks of SwiftUI, including its state flow and declarative syntax. SkipUI also implements many of SwiftUI's basic layout and control views, as well as many core modifiers. It is possible to write an Android app entirely in SwiftUI utilizing SkipUI's current component set. +SkipUI - together with the Skip transpiler - has robust support for the building blocks of SwiftUI, including its state flow and declarative syntax. SkipUI also implements a large percentage of SwiftUI's components and modifiers. It is possible to write an Android app entirely in SwiftUI utilizing SkipUI's current component set. -SkipUI is a young library, however, and much of SwiftUI's vast surface area is not yet implemented. You are likely to run into limitations while writing real-world apps. See [Supported SwiftUI](#supported-swiftui) for a full list of supported components and constructs. +SkipUI is a young library, however, and some of SwiftUI's vast surface area is not yet implemented. See [Supported SwiftUI](#supported-swiftui) for a full list of supported API. When you want to use a SwiftUI construct that has not been implemented, you have options. You can try to find a workaround using only supported components, [embed Compose code directly](#composeview), or [add support to SkipUI](#implementation-strategy). If you choose to enhance SkipUI itself, please consider [contributing](#contributing) your code back for inclusion in the official release. -## Contributing - -We welcome contributions to SkipUI. The Skip product [documentation](https://skip.tools/docs/contributing/) includes helpful instructions and tips on local Skip library development. - -The most pressing need is to implement more core components and view modifiers. View modifiers in particular are a ripe source of low-hanging fruit. The Compose `Modifier` type often has built-in functions that replicate SwiftUI modifiers, making these SwiftUI modifiers easy to implement. -To help fill in unimplemented API in SkipUI: - -1. Find unimplemented API. Unimplemented API will either be within `#if !SKIP` blocks, or will be marked with `@available(*, unavailable)`. Note that most unimplemented `View` modifiers are in the `View.swift` source file. -1. Write an appropriate Compose implementation. See [Implementation Strategy](#implementation-strategy) below. -1. Write tests and/or showcase code to exercise your component. See [Tests](#tests). -1. [Submit a PR](https://github.com/skiptools/skip-ui/pulls). - -Other forms of contributions such as test cases, comments, and documentation are also welcome! - -## Implementation Strategy - -### Code Transformations - -SkipUI does not work in isolation. It depends on transformations the [skip](https://source.skip.tools/skip) transpiler plugin makes to SwiftUI code. And while Skip generally strives to write Kotlin that is similar to hand-crafted code, these SwiftUI transformations are not something you'd want to write yourself. Before discussing SkipUI's implementation, let's explore them. - -Both SwiftUI and Compose are declarative UI frameworks. Both have mechanisms to track state and automatically re-render when state changes. SwiftUI models user interface elements with `View` objects, however, while Compose models them with `@Composable` functions. The Skip transpiler must therefore translate your code defining a `View` graph into `@Composable` function calls. This involves two primary transformations: - -1. The transpiler inserts code to sync `View` members that have special meanings in SwiftUI - `@State`, `@EnvironmentObject`, etc - with the corresponding Compose state mechanisms, which are not member-based. The syncing goes two ways, so that your `View` members are populated from Compose's state values, and changing your `View` members updates Compose's state values. -1. The transpiler turns `@ViewBuilders` - including `View.body` - into `@Composable` function calls. - -The second transformation in particular deserves some explanation, because it may help you to understand SkipUI's internal API. Consider the following simple example: - -```swift -struct V: View { - let isHello: Bool - - var body: some View { - if isHello { - Text("Hello!") - } else { - Text("Goodbye!") - } - } -} -``` - -The transpilation would look something like the following: - -```swift -class V: View { - val isHello: Bool - - constructor(isHello: Bool) { - this.isHello = isHello - } - - override fun body(): View { - return ComposeBuilder { composectx -> - if (isHello) { - Text("Hello!").Compose(context = composectx) - } else { - Text("Goodbye!").Compose(context = composectx) - } - ComposeResult.ok - } - } - - ... -} -``` - -Notice the changes to the `body` content. Rather than returning an arbitrary view tree, the transpiled `body` always returns a single `ComposeBuilder`, a special SkipUI view type that invokes a `@Composable` block. The logic of the original `body` is now within that block, and any `View` that `body` would have returned instead invokes its own `Compose(context:)` function to render the corresponding Compose component. The `Compose(context:)` function is part of SkipUI's `View` API. - -Thus the transpiler is able to turn any `View.body` - actually any `@ViewBuilder` - into a `ComposeBuilder`: a block of Compose code that it can invoke to render the desired content. A [later section](#composeview) details how you can use SkipUI's `ComposeView` yourself to move fluidly between SwiftUI and Compose when writing your Android UI. - -### Implementation Phases - -SkipUI contains stubs for the entire SwiftUI framework. API generally goes through three phases: - -1. Code that no one has begun to port to Skip starts in `#if !SKIP` blocks. This hides it from the Skip transpiler. -1. The first implementation step is to move code out of `#if !SKIP` blocks so that it will be transpiled. This is helpful on its own, even if you just mark the API `@available(*, unavailable)` because you are not ready to implement it for Compose. An `unavailable` attribute will provide Skip users with a clear error message, rather than relying on the Kotlin compiler to complain about unfound API. - - When moving code out of a `#if !SKIP` block, please strip Apple's extensive API comments. There is no reason for Skip to duplicate the official SwiftUI documentation, and it obscures any Skip-specific implementation comments we may add. - - SwiftUI uses complex generics extensively, and the generics systems of Swift and Kotlin have significant differences. You may have to replace some generics or generic constraints with looser typing in order to transpile successfully. Typing will still be enforced in user code by the Swift compiler. - - Reducing the number of Swift extensions and instead folding API into the primary declaration of a type can make Skip's internal symbol storage more efficient. You should, however, leave `View` modifiers that are specific to a given component - e.g. `.navigationTitle` is specific to `NavigationStack` - within the component's source file. -1. Finally, we add a Compose implementation and remove any `unavailable` attribute. - -Note that SkipUI should remain buildable throughout this process. Being able to successfully compile SkipUI in Xcode helps us validate that our ported components still mesh with the rest of the framework. - -### Components - -Before implementing a component, familiarize yourself with SkipUI's `View` protocol in `Sources/View/View.swift` as well as the files in the `Sources/Compose` directory. It is also helpful to browse the source code for components and modifiers that have already been ported. See the table of [Supported SwiftUI](#supported-swiftui). - -The `Text` view exemplifies a typical SwiftUI component implementation. Here is an abbreviated code sample: - -```swift -public struct Text: View, Equatable, Sendable { - let text: String - - public init(_ text: String) { - self.text = text - } - - ... - - #if SKIP - @Composable public override func ComposeContent(context: ComposeContext) { - let modifier = context.modifier - let font = EnvironmentValues.shared.font ?? Font(fontImpl: { LocalTextStyle.current }) - ... - androidx.compose.material3.Text(text: text, modifier: modifier, style: font.fontImpl(), ...) - } - #else - public var body: some View { - stubView() - } - #endif -} - -``` - -As you can see, the `Text` type is defined just as it is in SwiftUI. We then use an `#if SKIP` block to implement the composable `View.ComposeContent` function for Android, while we stub the `body` var to satisfy the Swift compiler. `ComposeContent` makes the necessary Compose calls to render the component, applying the modifier from the given `context` as well as any applicable environment values. If `Text` had any child views, `ComposeContent` would call `child.Compose(context: context.content())` to compose its child content. (Note that `View.Compose(context:)` delegates to `View.ComposeContent(context:)` after performing other bookkeeping operations, which is why we override `ComposeContent` rather than `Compose`.) - -### Modifiers - -Modifiers, on the other hand, use the `ComposeModifierView` to perform actions, including changing the `context` passed to the modified view. Here is the `.opacity` modifier: - -```swift -extension View { - public func opacity(_ opacity: Double) -> some View { - #if SKIP - return ComposeModifierView(targetView: self) { context in - context.modifier = context.modifier.alpha(Float(opacity)) - return ComposeResult.ok - } - #else - return self - #endif - } -} -``` - -Some modifiers have their own composition logic. These modifiers use a different `ComposeModifierView` constructor whose block defines the composition. Here, for example, `.frame` composes the view within a Compose `Box` with the proper dimensions and alignment: - -```swift -extension View { - public func frame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center) -> some View { - #if SKIP - return ComposeModifierView(contentView: self) { view, context in - var modifier = context.modifier - if let width { - modifier = modifier.width(width.dp) - } - if let height { - modifier = modifier.height(height.dp) - } - let contentContext = context.content() - ComposeContainer(modifier: modifier, fixedWidth: width != nil, fixedHeight: height != nil) { modifier in - Box(modifier: modifier, contentAlignment: alignment.asComposeAlignment()) { - view.Compose(context: contentContext) - } - } - } - #else - return self - #endif - } -} -``` - -Like other SwiftUI components, modifiers use `#if SKIP ... #else ...` to stub the Swift implementation and keep SkipUI buildable in Xcode. - ## ComposeView `ComposeView` is an Android-only SwiftUI view that you can use to embed Compose code directly into your SwiftUI view tree. In the following example, we use a SwiftUI `Text` to write "Hello from SwiftUI", followed by calling the `androidx.compose.material3.Text()` Compose function to write "Hello from Compose" below it: @@ -275,12 +108,197 @@ TextField("Enter username:", text: $username) Like `ComposeView`, the `composeModifier` function is only available in Android, so you must guard all uses with the `#if SKIP` or `#if os(Android)` compiler directives. -## Tests +## Material -SkipUI utilizes a combination of unit tests, UI tests, and basic snapshot tests in which the snapshots are converted into ASCII art for easy processing. +Under the hood, SkipUI uses Android's Material 3 colors and components. While we expect you to use SwiftUI's built-in color schemes (`.preferredColorScheme`) and modifiers (`.background`, `.foregroundStyle`, `.tint`, and so on) for most UI styling, there are some Android customizations that have no SwiftUI equivalent. Skip therefore adds additional, Android-only API for manipulating Material colors and components. -Perhaps the most common way to test SkipUI's support for a SwiftUI component, however, is through the [Skip Showcase app](https://github.com/skiptools/skipapp-showcase). Whenever you add or update support for a visible element of SwiftUI, make sure there is a showcase view that exercises the element. This not only gives us a mechanism to test appearance and behavior, but the showcase app becomes a demonstration of supported SwiftUI components on Android over time. +### Material Colors + +By default, Skip uses Material 3's dynamic colors on devices that support them, and falls back to Material 3's standard colors otherwise. You can customize these colors in Compose code using the following function: + +```kotlin +Material3ColorScheme(scheme: (@Composable (ColorScheme, Boolean) -> ColorScheme)?, content: @Composable () -> Unit) +``` + +The `scheme` argument takes a closure with two arguments: Skip's default `androidx.compose.material3.ColorScheme`, and whether dark mode is being requested. Your closure returns the `androidx.compose.material3.ColorScheme` to use for the supplied content. + +For example, to customize the surface colors for your entire app, you could edit `Main.kt` as follows: + +```kotlin +@Composable +internal fun PresentationRootView(context: ComposeContext) { + Material3ColorScheme({ colors, isDark -> + colors.copy(surface = if (isDark) Color.purple.asComposeColor() else Color.yellow.asComposeColor()) + }, content = { + // ... Original content of this function ... + }) +} +``` + +Skip also provides the SwiftUI `.material3ColorScheme(_:)` modifier to customize a SwiftUI view hierarchy. The modifier takes the same closure as the `Material3ColorScheme` Kotlin function. It is only available for Android, so you must use it within a `#if SKIP` block. For example: + +```swift +MyView() + #if SKIP + .material3ColorScheme { colors, isDark in + colors.copy(surface: isDark ? Color.purple.asComposeColor() : Color.yellow.asComposeColor()) + } + #endif +``` + +Skip/'s built-in components use the following Material 3 colors, if you'd like to customize them: + +- `surface` +- `primary` +- `onBackground` +- `outline` +- `outlineVariant` + +### Material Components + +In addition to the `.material3ColorScheme` modifier detailed above, Skip includes many other `.material3` modifiers for its underlying Material 3 components. This family of modifiers share a common API pattern: + +- The modifiers take a closure argument. This closure receives a `Material3Options` struct configured with Skip's defaults, and it returns a struct with any desired modifications. +- Every `Material3Options` struct implements a conventional Kotlin `copy` method. This allows you to copy and modify the struct in a single call. +- The modifiers place your closure into the SwiftUI `Environment`. This means that you can apply the modifier on a root view, and it will affect all subviews. While you may be used to placing navigation and tab bar modifiers on the views *within* the `NavigationStack` or `TabView`, the `.material3` family of modifiers always go *on or outside* the views you want to affect. +- Because they are designed to reach beneath Skip's SwiftUI covers, the modifiers use Compose terminology and types. In fact the properties of the supplied `Material3Options` structs typically exactly match the corresponding Material component function parameters. + +You can find details on Material 3 component API in [this Android API documentation](https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary). +{: class="callout info"} + +Here is an example of changing the selected indicator color on your Android tab bar, which is implemented by the Material 3 `NavigationBar` component: + +```swift +TabView { + ... +} +#if SKIP +.material3NavigationBar { options in + let updatedColors = options.itemColors.copy(selectedIndicatorColor: Color.green.asComposeColor()) + return options.copy(itemColors: updatedColors) +} +#endif +``` +SkipUI currently includes the following Material modifiers: + +```swift +extension View { + public func material3BottomAppBar(_ options: @Composable (Material3BottomAppBarOptions) -> Material3BottomAppBarOptions) -> some View +} + +public struct Material3BottomAppBarOptions { +} + +extension View { + public func material3Button(_ options: @Composable (Material3ButtonOptions) -> Material3ButtonOptions) -> some View +} + +public struct Material3ButtonOptions { + public var modifier: Modifier + public var containerColor: Color + public var contentColor: Color + public var tonalElevation: Dp + public var contentPadding: PaddingValues +} + +extension View { + public func material3NavigationBar(_ options: @Composable (Material3NavigationBarOptions) -> Material3NavigationBarOptions) -> some View +} + +public struct Material3NavigationBarOptions { + public var modifier: Modifier + public var containerColor: Color + public var contentColor: Color + public var tonalElevation: Dp + public var onItemClick: (Int) -> Void + public var itemIcon: @Composable (Int) -> Void + public var itemModifier: @Composable (Int) -> Modifier + public var itemEnabled: (Int) -> Boolean + public var itemLabel: (@Composable (Int) -> Void)? + public var alwaysShowItemLabels: Bool + public var itemColors: NavigationBarItemColors + public var itemInteractionSource: MutableInteractionSource? +} + +extension View { + public func material3Text(_ options: @Composable (Material3TextOptions) -> Material3TextOptions) -> some View +} + +public struct Material3TextOptions { + public var text: String? + public var annotatedText: AnnotatedString? + public var modifier: Modifier + public var color: Color + public var fontSize: TextUnit + public var fontStyle: FontStyle? + public var fontWeight: FontWeight? + public var fontFamily: FontFamily? + public var letterSpacing: TextUnit + public var textDecoration: TextDecoration? + public var textAlign: TextAlign? + public var lineHeight: TextUnit + public var overflow: TextOverflow + public var softWrap: Bool + public var maxLines: Int + public var minLines: Int + public var onTextLayout: ((TextLayoutResult) -> Void)? + public var style: TextStyle +} + +extension View { + public func material3TextField(_ options: @Composable (Material3TextFieldOptions) -> Material3TextFieldOptions) -> some View +} + +public struct Material3TextFieldOptions { + public var value: String + public var onValueChange: (String) -> Void + public var modifier: Modifier + public var enabled: Bool + public var readOnly: Bool + public var textStyle: TextStyle + public var label: (@Composable () -> Void)? + public var placeholder: (@Composable () -> Void)? + public var leadingIcon: (@Composable () -> Void)? + public var trailingIcon: (@Composable () -> Void)? + public var prefix: (@Composable () -> Void)? + public var suffix: (@Composable () -> Void)? + public var supportingText: (@Composable () -> Void)? + public var isError: Bool + public var visualTransformation: VisualTransformation + public var keyboardOptions: KeyboardOptions + public var keyboardActions: KeyboardActions + public var singleLine: Bool + public var maxLines: Int + public var minLines: Int + public var interactionSource: MutableInteractionSource? + public var shape: Shape + public var colors: TextFieldColors +} + +extension View { + public func material3TopAppBar(_ options: @Composable (Material3TopAppBarOptions) -> Material3TopAppBarOptions) -> some View +} + +public struct Material3TopAppBarOptions { + public var title: @Composable () -> Void + public var modifier: Modifier + public var navigationIcon: @Composable () -> Void + public var colors: TopAppBarColors + public var scrollBehavior: TopAppBarScrollBehavior? + public var preferCenterAlignedStyle: Bool + public var preferLargeStyle: Bool +} +``` + +Note that `.material3TopAppBar` involves API that Compose deems experimental, so to do so you have to add the following to your `View` declaration: + +```swift +// SKIP INSERT: @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +struct MyView: View { + ... +} +``` ## Supported SwiftUI @@ -388,7 +406,6 @@ Support levels:
  • static let brown: Color
  • func opacity(_ opacity: Double) -> Color
  • var gradient: AnyGradient
  • -
  • See also Colors
  • @@ -1048,7 +1065,7 @@ Support levels:
    .colorScheme
    @@ -1348,7 +1365,7 @@ Support levels:
    .preferredColorScheme
    @@ -1854,49 +1871,7 @@ Skip converts the various SwiftUI animation types to their Compose equivalents. Custom `Animatables` and `Transitions` are not supported. Finally, if you nest `withAnimation` blocks, Android will apply the innermost animation to all block actions. -### Colors - -Skip adds additional Android-only API that allows you to customize the Material colors used for your app's light and dark colors schemes. By default, Skip uses Material 3's dynamic colors on devices that support them, and falls back to Material 3's standard colors otherwise. You can customize these colors in Compose code using the following function: - -```kotlin -Material3ColorScheme(scheme: (@Composable (ColorScheme, Boolean) -> ColorScheme)?, content: @Composable () -> Unit) -``` - -The `scheme` argument takes a closure with two arguments: the default `androidx.compose.material3.ColorScheme`, and whether dark mode is being requested. Your closure returns the `androidx.compose.material3.ColorScheme` to use for the supplied content. - -For example, to customize the surface colors for your app, you could edit `Main.kt` as follows: - -```kotlin -@Composable -internal fun PresentationRootView(context: ComposeContext) { - Material3ColorScheme({ colors, isDark -> - colors.copy(surface = if (isDark) Color.purple.colorImpl() else Color.yellow.colorImpl()) - }, content = { - // ... Original content of this function ... - }) -} -``` - -Skip also provides the SwiftUI `.material3ColorScheme(_:)` modifier to customize a SwiftUI view hierarchy. The modifier takes the same closure as the `Material3ColorScheme` Kotlin function. It is only available for Android, so you must use it within a `#if SKIP` block. For example: - -```swift -MyView() - #if SKIP - .material3ColorScheme { colors, isDark in - colors.copy(surface: isDark ? Color.purple.colorImpl() : Color.yellow.colorImpl()) - } - #endif -``` - -Skips built-in components use the following Material 3 colors, if you'd like to customize them: - -- `surface` -- `primary` -- `onBackground` -- `outline` -- `outlineVariant` - -#### Preferred Color Scheme +### ColorScheme SkipUI fully supports the `.preferredColorScheme` modifier. If you created your app with the `skip` tool prior to v0.8.26, however, you will have to update the included `Android/app/src/main/kotlin/.../Main.kt` file in order for the modifier to work correctly. Using the latest [`Main.kt`](https://github.com/skiptools/skipapp-hello/blob/main/Android/app/src/main/kotlin/hello/skip/Main.kt) as your template, please do the following: @@ -2316,3 +2291,175 @@ override fun onCreate(savedInstanceState: android.os.Bundle?) { ``` With these updates in place, your app should extend below the system bars. If you're running a modern SkipUI version and want to *disable* edge-to-edge mode, simply remove the `enableEdgeToEdge()` call. + +## Contributing + +We welcome contributions to SkipUI. The Skip product [documentation](https://skip.tools/docs/contributing/) includes helpful instructions and tips on local Skip library development. + +The most pressing need is to implement more core components and view modifiers. View modifiers in particular are a ripe source of low-hanging fruit. The Compose `Modifier` type often has built-in functions that replicate SwiftUI modifiers, making these SwiftUI modifiers easy to implement. +To help fill in unimplemented API in SkipUI: + +1. Find unimplemented API. Unimplemented API will either be within `#if !SKIP` blocks, or will be marked with `@available(*, unavailable)`. Note that most unimplemented `View` modifiers are in the `View.swift` source file. +1. Write an appropriate Compose implementation. See [Implementation Strategy](#implementation-strategy) below. +1. Write tests and/or showcase code to exercise your component. See [Tests](#tests). +1. [Submit a PR](https://github.com/skiptools/skip-ui/pulls). + +Other forms of contributions such as test cases, comments, and documentation are also welcome! + +## Tests + +SkipUI utilizes a combination of unit tests, UI tests, and basic snapshot tests in which the snapshots are converted into ASCII art for easy processing. + +Perhaps the most common way to test SkipUI's support for a SwiftUI component, however, is through the [Skip Showcase app](https://github.com/skiptools/skipapp-showcase). Whenever you add or update support for a visible element of SwiftUI, make sure there is a showcase view that exercises the element. This not only gives us a mechanism to test appearance and behavior, but the showcase app becomes a demonstration of supported SwiftUI components on Android over time. + +## Implementation Strategy + +### Code Transformations + +SkipUI does not work in isolation. It depends on transformations the [skip](https://source.skip.tools/skip) transpiler plugin makes to SwiftUI code. And while Skip generally strives to write Kotlin that is similar to hand-crafted code, these SwiftUI transformations are not something you'd want to write yourself. Before discussing SkipUI's implementation, let's explore them. + +Both SwiftUI and Compose are declarative UI frameworks. Both have mechanisms to track state and automatically re-render when state changes. SwiftUI models user interface elements with `View` objects, however, while Compose models them with `@Composable` functions. The Skip transpiler must therefore translate your code defining a `View` graph into `@Composable` function calls. This involves two primary transformations: + +1. The transpiler inserts code to sync `View` members that have special meanings in SwiftUI - `@State`, `@EnvironmentObject`, etc - with the corresponding Compose state mechanisms, which are not member-based. The syncing goes two ways, so that your `View` members are populated from Compose's state values, and changing your `View` members updates Compose's state values. +1. The transpiler turns `@ViewBuilders` - including `View.body` - into `@Composable` function calls. + +The second transformation in particular deserves some explanation, because it may help you to understand SkipUI's internal API. Consider the following simple example: + +```swift +struct V: View { + let isHello: Bool + + var body: some View { + if isHello { + Text("Hello!") + } else { + Text("Goodbye!") + } + } +} +``` + +The transpilation would look something like the following: + +```swift +class V: View { + val isHello: Bool + + constructor(isHello: Bool) { + this.isHello = isHello + } + + override fun body(): View { + return ComposeBuilder { composectx -> + if (isHello) { + Text("Hello!").Compose(context = composectx) + } else { + Text("Goodbye!").Compose(context = composectx) + } + ComposeResult.ok + } + } + + ... +} +``` + +Notice the changes to the `body` content. Rather than returning an arbitrary view tree, the transpiled `body` always returns a single `ComposeBuilder`, a special SkipUI view type that invokes a `@Composable` block. The logic of the original `body` is now within that block, and any `View` that `body` would have returned instead invokes its own `Compose(context:)` function to render the corresponding Compose component. The `Compose(context:)` function is part of SkipUI's `View` API. + +Thus the transpiler is able to turn any `View.body` - actually any `@ViewBuilder` - into a `ComposeBuilder`: a block of Compose code that it can invoke to render the desired content. A [later section](#composeview) details how you can use SkipUI's `ComposeView` yourself to move fluidly between SwiftUI and Compose when writing your Android UI. + +### Implementation Phases + +SkipUI contains stubs for the entire SwiftUI framework. API generally goes through three phases: + +1. Code that no one has begun to port to Skip starts in `#if !SKIP` blocks. This hides it from the Skip transpiler. +1. The first implementation step is to move code out of `#if !SKIP` blocks so that it will be transpiled. This is helpful on its own, even if you just mark the API `@available(*, unavailable)` because you are not ready to implement it for Compose. An `unavailable` attribute will provide Skip users with a clear error message, rather than relying on the Kotlin compiler to complain about unfound API. + - When moving code out of a `#if !SKIP` block, please strip Apple's extensive API comments. There is no reason for Skip to duplicate the official SwiftUI documentation, and it obscures any Skip-specific implementation comments we may add. + - SwiftUI uses complex generics extensively, and the generics systems of Swift and Kotlin have significant differences. You may have to replace some generics or generic constraints with looser typing in order to transpile successfully. Typing will still be enforced in user code by the Swift compiler. + - Reducing the number of Swift extensions and instead folding API into the primary declaration of a type can make Skip's internal symbol storage more efficient. You should, however, leave `View` modifiers that are specific to a given component - e.g. `.navigationTitle` is specific to `NavigationStack` - within the component's source file. +1. Finally, we add a Compose implementation and remove any `unavailable` attribute. + +Note that SkipUI should remain buildable throughout this process. Being able to successfully compile SkipUI in Xcode helps us validate that our ported components still mesh with the rest of the framework. + +### Components + +Before implementing a component, familiarize yourself with SkipUI's `View` protocol in `Sources/View/View.swift` as well as the files in the `Sources/Compose` directory. It is also helpful to browse the source code for components and modifiers that have already been ported. See the table of [Supported SwiftUI](#supported-swiftui). + +The `Text` view exemplifies a typical SwiftUI component implementation. Here is an abbreviated code sample: + +```swift +public struct Text: View, Equatable, Sendable { + let text: String + + public init(_ text: String) { + self.text = text + } + + ... + + #if SKIP + @Composable public override func ComposeContent(context: ComposeContext) { + let modifier = context.modifier + let font = EnvironmentValues.shared.font ?? Font(fontImpl: { LocalTextStyle.current }) + ... + androidx.compose.material3.Text(text: text, modifier: modifier, style: font.fontImpl(), ...) + } + #else + public var body: some View { + stubView() + } + #endif +} + +``` + +As you can see, the `Text` type is defined just as it is in SwiftUI. We then use an `#if SKIP` block to implement the composable `View.ComposeContent` function for Android, while we stub the `body` var to satisfy the Swift compiler. `ComposeContent` makes the necessary Compose calls to render the component, applying the modifier from the given `context` as well as any applicable environment values. If `Text` had any child views, `ComposeContent` would call `child.Compose(context: context.content())` to compose its child content. (Note that `View.Compose(context:)` delegates to `View.ComposeContent(context:)` after performing other bookkeeping operations, which is why we override `ComposeContent` rather than `Compose`.) + +### Modifiers + +Modifiers, on the other hand, use the `ComposeModifierView` to perform actions, including changing the `context` passed to the modified view. Here is the `.opacity` modifier: + +```swift +extension View { + public func opacity(_ opacity: Double) -> some View { + #if SKIP + return ComposeModifierView(targetView: self) { context in + context.modifier = context.modifier.alpha(Float(opacity)) + return ComposeResult.ok + } + #else + return self + #endif + } +} +``` + +Some modifiers have their own composition logic. These modifiers use a different `ComposeModifierView` constructor whose block defines the composition. Here, for example, `.frame` composes the view within a Compose `Box` with the proper dimensions and alignment: + +```swift +extension View { + public func frame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center) -> some View { + #if SKIP + return ComposeModifierView(contentView: self) { view, context in + var modifier = context.modifier + if let width { + modifier = modifier.width(width.dp) + } + if let height { + modifier = modifier.height(height.dp) + } + let contentContext = context.content() + ComposeContainer(modifier: modifier, fixedWidth: width != nil, fixedHeight: height != nil) { modifier in + Box(modifier: modifier, contentAlignment: alignment.asComposeAlignment()) { + view.Compose(context: contentContext) + } + } + } + #else + return self + #endif + } +} +``` + +Like other SwiftUI components, modifiers use `#if SKIP ... #else ...` to stub the Swift implementation and keep SkipUI buildable in Xcode. diff --git a/Sources/SkipUI/SkipUI/Color/Color.swift b/Sources/SkipUI/SkipUI/Color/Color.swift index b0b05ce0..e12d0e5d 100644 --- a/Sources/SkipUI/SkipUI/Color/Color.swift +++ b/Sources/SkipUI/SkipUI/Color/Color.swift @@ -29,6 +29,11 @@ public struct Color: ShapeStyle, Hashable, Sendable { Box(modifier: modifier) } + /// Return the equivalent Compose color. + @Composable public func asComposeColor() -> androidx.compose.ui.graphics.Color { + return colorImpl() + } + // MARK: - ShapeStyle @Composable override func asColor(opacity: Double, animationContext: ComposeContext?) -> androidx.compose.ui.graphics.Color? { diff --git a/Sources/SkipUI/SkipUI/Color/ColorScheme.swift b/Sources/SkipUI/SkipUI/Color/ColorScheme.swift index 0a86b066..f8d28707 100644 --- a/Sources/SkipUI/SkipUI/Color/ColorScheme.swift +++ b/Sources/SkipUI/SkipUI/Color/ColorScheme.swift @@ -38,7 +38,7 @@ public enum ColorScheme : CaseIterable, Hashable, Sendable { } else { colorScheme = isDynamicColor ? dynamicLightColorScheme(context) : lightColorScheme() } - guard let customization = EnvironmentValues.shared._materialColorScheme else { + guard let customization = EnvironmentValues.shared._material3ColorScheme else { return colorScheme } return customization(colorScheme, isDarkMode) @@ -73,7 +73,7 @@ extension View { } public func material3ColorScheme(_ scheme: (@Composable (androidx.compose.material3.ColorScheme, Bool) -> androidx.compose.material3.ColorScheme)?) -> some View { - return environment(\._materialColorScheme, scheme) + return environment(\._material3ColorScheme, scheme) } #endif } @@ -85,7 +85,7 @@ extension View { @Composable public func Material3ColorScheme(_ scheme: (@Composable (androidx.compose.material3.ColorScheme, Bool) -> androidx.compose.material3.ColorScheme)?, content: @Composable () -> Void) { EnvironmentValues.shared.setValues { - $0.set_materialColorScheme(scheme) + $0.set_material3ColorScheme(scheme) } in: { content() } diff --git a/Sources/SkipUI/SkipUI/Containers/Navigation.swift b/Sources/SkipUI/SkipUI/Containers/Navigation.swift index f17925c2..88407790 100644 --- a/Sources/SkipUI/SkipUI/Containers/Navigation.swift +++ b/Sources/SkipUI/SkipUI/Containers/Navigation.swift @@ -39,8 +39,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.contentColorFor import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -337,15 +342,27 @@ public struct NavigationStack : View where Root: View { let toolbarItemContext = context.content(modifier: Modifier.padding(start: 12.dp, end: 12.dp)) topTrailingItems.forEach { $0.Compose(context: toolbarItemContext) } } + var options = Material3TopAppBarOptions(title: topBarTitle, modifier: topBarModifier, navigationIcon: topBarNavigationIcon, colors: topBarColors, scrollBehavior: scrollBehavior) + if let updateOptions = EnvironmentValues.shared._material3TopAppBar { + options = updateOptions(options) + } if isInlineTitleDisplayMode { - TopAppBar(modifier: topBarModifier, colors: topBarColors, title: topBarTitle, navigationIcon: topBarNavigationIcon, actions: { topBarActions() }, scrollBehavior: scrollBehavior) + if options.preferCenterAlignedStyle { + CenterAlignedTopAppBar(title: options.title, modifier: options.modifier, navigationIcon: options.navigationIcon, actions: { topBarActions() }, colors: options.colors, scrollBehavior: options.scrollBehavior) + } else { + TopAppBar(title: options.title, modifier: options.modifier, navigationIcon: options.navigationIcon, actions: { topBarActions() }, colors: options.colors, scrollBehavior: options.scrollBehavior) + } } else { // Force a larger, bold title style in the uncollapsed state by replacing the headlineSmall style the bar uses let typography = MaterialTheme.typography let appBarTitleStyle = typography.headlineLarge.copy(fontWeight: FontWeight.Bold) let appBarTypography = typography.copy(headlineSmall: appBarTitleStyle) MaterialTheme(colorScheme: MaterialTheme.colorScheme, typography: appBarTypography, shapes: MaterialTheme.shapes) { - MediumTopAppBar(modifier: topBarModifier, colors: topBarColors, title: topBarTitle, navigationIcon: topBarNavigationIcon, actions: { topBarActions() }, scrollBehavior: scrollBehavior) + if options.preferLargeStyle { + LargeTopAppBar(title: options.title, modifier: options.modifier, navigationIcon: options.navigationIcon, actions: { topBarActions() }, colors: options.colors, scrollBehavior: options.scrollBehavior) + } else { + MediumTopAppBar(title: options.title, modifier: options.modifier, navigationIcon: options.navigationIcon, actions: { topBarActions() }, colors: options.colors, scrollBehavior: options.scrollBehavior) + } } } } @@ -427,7 +444,11 @@ public struct NavigationStack : View where Root: View { PaddingLayout(padding: EdgeInsets(top: 0.0, leading: 0.0, bottom: Double(-bottomPadding.value), trailing: 0.0), context: context.content()) { context in let containerColor = canScrollForward ? bottomBarBackgroundColor : unscrolledBottomBarBackgroundColor let windowInsets = EnvironmentValues.shared._isEdgeToEdge == true ? BottomAppBarDefaults.windowInsets : WindowInsets(bottom: 0.dp) - BottomAppBar(modifier: context.modifier.then(bottomBarModifier), containerColor: containerColor, contentPadding: PaddingValues.Absolute(left: 16.dp, right: 16.dp), windowInsets: windowInsets) { + var options = Material3BottomAppBarOptions(modifier: context.modifier.then(bottomBarModifier), containerColor: containerColor, contentColor: MaterialTheme.colorScheme.contentColorFor(containerColor), contentPadding: PaddingValues.Absolute(left: 16.dp, right: 16.dp)) + if let updateOptions = EnvironmentValues.shared._material3BottomAppBar { + options = updateOptions(options) + } + BottomAppBar(modifier: options.modifier, containerColor: options.containerColor, contentColor: options.contentColor, tonalElevation: options.tonalElevation, contentPadding: options.contentPadding, windowInsets: windowInsets) { // Use an HStack so that it sets up the environment for bottom toolbar Spacers HStack(spacing: 24.0) { ComposeBuilder { itemContext in @@ -910,9 +931,60 @@ extension View { public func navigationTitle(_ title: Binding) -> some View { return self } + + #if SKIP + public func material3TopAppBar(_ options: @Composable (Material3TopAppBarOptions) -> Material3TopAppBarOptions) -> View { + return environment(\._material3TopAppBar, options) + } + + public func material3BottomAppBar(_ options: @Composable (Material3BottomAppBarOptions) -> Material3BottomAppBarOptions) -> View { + return environment(\._material3BottomAppBar, options) + } + #endif } #if SKIP +// SKIP INSERT: @OptIn(ExperimentalMaterial3Api::class) +public struct Material3TopAppBarOptions { + public var title: @Composable () -> Void + public var modifier: Modifier = Modifier + public var navigationIcon: @Composable () -> Void = {} + public var colors: TopAppBarColors + public var scrollBehavior: TopAppBarScrollBehavior? = nil + public var preferCenterAlignedStyle = false + public var preferLargeStyle = false + + public func copy( + title: @Composable () -> Void = self.title, + modifier: Modifier = self.modifier, + navigationIcon: @Composable () -> Void = self.navigationIcon, + colors: TopAppBarColors = self.colors, + scrollBehavior: TopAppBarScrollBehavior? = self.scrollBehavior, + preferCenterAlignedStyle: Bool = self.preferCenterAlignedStyle, + preferLargeStyle: Bool = self.preferLargeStyle + ) -> Material3TopAppBarOptions { + return Material3TopAppBarOptions(title: title, modifier: modifier, navigationIcon: navigationIcon, colors: colors, scrollBehavior: scrollBehavior, preferCenterAlignedStyle: preferCenterAlignedStyle, preferLargeStyle: preferLargeStyle) + } +} + +public struct Material3BottomAppBarOptions { + public var modifier: Modifier = Modifier + public var containerColor: androidx.compose.ui.graphics.Color + public var contentColor: androidx.compose.ui.graphics.Color + public var tonalElevation: Dp = BottomAppBarDefaults.ContainerElevation + public var contentPadding: PaddingValues = BottomAppBarDefaults.ContentPadding + + public func copy( + modifier: Modifier = self.modifier, + containerColor: androidx.compose.ui.graphics.Color = self.containerColor, + contentColor: androidx.compose.ui.graphics.Color = self.contentColor, + tonalElevation: Dp = self.tonalElevation, + contentPadding: PaddingValues = self.contentPadding + ) -> Material3BottomAppBarOptions { + return Material3BottomAppBarOptions(modifier: modifier, containerColor: containerColor, contentColor: contentColor, tonalElevation: tonalElevation, contentPadding: contentPadding) + } +} + struct NavigationDestinationsPreferenceKey: PreferenceKey { typealias Value = NavigationDestinations diff --git a/Sources/SkipUI/SkipUI/Containers/TabView.swift b/Sources/SkipUI/SkipUI/Containers/TabView.swift index 87ae9799..509a428f 100644 --- a/Sources/SkipUI/SkipUI/Containers/TabView.swift +++ b/Sources/SkipUI/SkipUI/Containers/TabView.swift @@ -9,6 +9,7 @@ import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,15 +25,18 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarDefaults import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemColors import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable @@ -42,6 +46,7 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -168,23 +173,46 @@ public struct TabView : View { // Pull the tab bar below the keyboard let bottomPadding = with(density) { min(bottomBarHeightPx.value, Float(WindowInsets.ime.getBottom(density))).toDp() } PaddingLayout(padding: EdgeInsets(top: 0.0, leading: 0.0, bottom: Double(-bottomPadding.value), trailing: 0.0), context: context.content()) { context in + let tabItemsState = rememberUpdatedState(tabItems) let containerColor = canScrollForward ? tabBarBackgroundColor : unscrolledTabBarBackgroundColor - NavigationBar(modifier: context.modifier.then(tabBarModifier), containerColor: containerColor) { + let onItemClick: (Int) -> Void = { tabIndex in + let route = String(describing: tabIndex) + if let selection, let tagValue = tagValue(route: route, in: tabViews) { + selection.wrappedValue = tagValue + } else { + navigate(controller: navController, route: route) + } + } + let itemIcon: @Composable (Int) -> Void = { tabIndex in + let tabItem = tabItemsState.value[tabIndex] + tabItem?.ComposeImage(context: tabItemContext) + } + let itemLabel: @Composable (Int) -> Void = { tabIndex in + let tabItem = tabItemsState.value[tabIndex] + tabItem?.ComposeTitle(context: tabItemContext) + } + var options = Material3NavigationBarOptions(modifier: context.modifier.then(tabBarModifier), containerColor: containerColor, contentColor: MaterialTheme.colorScheme.contentColorFor(containerColor), onItemClick: onItemClick, itemIcon: itemIcon, itemLabel: itemLabel, itemColors: tabBarItemColors) + if let updateOptions = EnvironmentValues.shared._material3NavigationBar { + options = updateOptions(options) + } + NavigationBar(modifier: options.modifier, containerColor: options.containerColor, contentColor: options.contentColor, tonalElevation: options.tonalElevation) { for tabIndex in 0.. Void)? + if let itemLabel = options.itemLabel { + label = { itemLabel(tabIndex) } + } else { + label = nil + } + NavigationBarItem(selected: route == currentRoute, + onClick: { options.onItemClick(tabIndex) }, + icon: { options.itemIcon(tabIndex) }, + modifier: options.itemModifier(tabIndex), + enabled: options.itemEnabled(tabIndex), + label: label, + alwaysShowLabel: options.alwaysShowItemLabels, + colors: options.itemColors, + interactionSource: options.itemInteractionSource ) } } @@ -414,7 +442,47 @@ extension View { // We only support .automatic return self } + + #if SKIP + public func material3NavigationBar(_ options: @Composable (Material3NavigationBarOptions) -> Material3NavigationBarOptions) -> View { + return environment(\._material3NavigationBar, options) + } + #endif +} + +#if SKIP +public struct Material3NavigationBarOptions { + public var modifier: Modifier = Modifier + public var containerColor: androidx.compose.ui.graphics.Color + public var contentColor: androidx.compose.ui.graphics.Color + public var tonalElevation: Dp = NavigationBarDefaults.Elevation + public var onItemClick: (Int) -> Void + public var itemIcon: @Composable (Int) -> Void + public var itemModifier: @Composable (Int) -> Modifier = { _ in Modifier } + public var itemEnabled: (Int) -> Boolean = { _ in true } + public var itemLabel: (@Composable (Int) -> Void)? = nil + public var alwaysShowItemLabels = true + public var itemColors: NavigationBarItemColors + public var itemInteractionSource: MutableInteractionSource? = nil + + public func copy( + modifier: Modifier = self.modifier, + containerColor: androidx.compose.ui.graphics.Color = self.containerColor, + contentColor: androidx.compose.ui.graphics.Color = self.contentColor, + tonalElevation: Dp = self.tonalElevation, + onItemClick: (Int) -> Void = self.onItemClick, + itemIcon: @Composable (Int) -> Void = self.itemIcon, + itemModifier: @Composable (Int) -> Modifier = self.itemModifier, + itemEnabled: (Int) -> Boolean = self.itemEnabled, + itemLabel: (@Composable (Int) -> Void)? = self.itemLabel, + alwaysShowItemLabels: Bool = self.alwaysShowItemLabels, + itemColors: NavigationBarItemColors = self.itemColors, + itemInteractionSource: MutableInteractionSource? = self.itemInteractionSource + ) -> Material3NavigationBarOptions { + return Material3NavigationBarOptions(modifier: modifier, containerColor: containerColor, contentColor: contentColor, tonalElevation: tonalElevation, onItemClick: onItemClick, itemIcon: itemIcon, itemModifier: itemModifier, itemEnabled: itemEnabled, itemLabel: itemLabel, alwaysShowItemLabels: alwaysShowItemLabels, itemColors: itemColors, itemInteractionSource: itemInteractionSource) + } } +#endif #if false diff --git a/Sources/SkipUI/SkipUI/Controls/Button.swift b/Sources/SkipUI/SkipUI/Controls/Button.swift index ecc3e7a3..c1909d24 100644 --- a/Sources/SkipUI/SkipUI/Controls/Button.swift +++ b/Sources/SkipUI/SkipUI/Controls/Button.swift @@ -5,12 +5,16 @@ // as published by the Free Software Foundation https://fsf.org #if SKIP +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.ContentAlpha import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonElevation import androidx.compose.material3.FilledTonalButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -83,12 +87,16 @@ public struct Button : View, ListItemAdapting { } else { colors = ButtonDefaults.filledTonalButtonColors() } + var options = Material3ButtonOptions(onClick: action, modifier: modifier, enabled: isEnabled, shape: ButtonDefaults.filledTonalShape, colors: colors, elevation: ButtonDefaults.filledTonalButtonElevation()) + if let updateOptions = EnvironmentValues.shared._material3Button { + options = updateOptions(options) + } let placement = EnvironmentValues.shared._placement let contentContext = context.content() EnvironmentValues.shared.setValues { $0.set_placement(placement.union(ViewPlacement.systemTextColor)) } in: { - FilledTonalButton(onClick: action, modifier: modifier, enabled: isEnabled, colors: colors) { + FilledTonalButton(onClick: options.onClick, modifier: options.modifier, enabled: options.enabled, shape: options.shape, colors: options.colors, elevation: options.elevation, border: options.border, contentPadding: options.contentPadding, interactionSource: options.interactionSource) { label.Compose(context: contentContext) } } @@ -101,12 +109,16 @@ public struct Button : View, ListItemAdapting { } else { colors = ButtonDefaults.buttonColors() } + var options = Material3ButtonOptions(onClick: action, modifier: modifier, enabled: isEnabled, shape: ButtonDefaults.shape, colors: colors, elevation: ButtonDefaults.buttonElevation()) + if let updateOptions = EnvironmentValues.shared._material3Button { + options = updateOptions(options) + } let placement = EnvironmentValues.shared._placement let contentContext = context.content() EnvironmentValues.shared.setValues { $0.set_placement(placement.union(ViewPlacement.systemTextColor)) } in: { - androidx.compose.material3.Button(onClick: action, modifier: modifier, enabled: isEnabled, colors: colors) { + androidx.compose.material3.Button(onClick: options.onClick, modifier: options.modifier, enabled: options.enabled, shape: options.shape, colors: options.colors, elevation: options.elevation, border: options.border, contentPadding: options.contentPadding, interactionSource: options.interactionSource) { label.Compose(context: contentContext) } } @@ -196,8 +208,43 @@ extension View { public func buttonBorderShape(_ shape: Any) -> some View { return self } + + #if SKIP + /// Compose button customization. + public func material3Button(_ options: @Composable (Material3ButtonOptions) -> Material3ButtonOptions) -> View { + return environment(\._material3Button, options) + } + #endif } +#if SKIP +public struct Material3ButtonOptions { + public var onClick: () -> Void + public var modifier: Modifier = Modifier + public var enabled = true + public var shape: androidx.compose.ui.graphics.Shape + public var colors: ButtonColors + public var elevation: ButtonElevation? = nil + public var border: BorderStroke? = nil + public var contentPadding: PaddingValues = ButtonDefaults.ContentPadding + public var interactionSource: MutableInteractionSource? = nil + + public func copy( + onClick: () -> Void = self.onClick, + modifier: Modifier = self.modifier, + enabled: Bool = self.enabled, + shape: androidx.compose.ui.graphics.Shape = self.shape, + colors: ButtonColors = self.colors, + elevation: ButtonElevation? = self.elevation, + border: BorderStroke? = self.border, + contentPadding: PaddingValues = self.contentPadding, + interactionSource: MutableInteractionSource? = self.interactionSource + ) -> Material3ButtonOptions { + return Material3ButtonOptions(onClick: onClick, modifier: modifier, enabled: enabled, shape: shape, colors: colors, elevation: elevation, border: border, contentPadding: contentPadding, interactionSource: interactionSource) + } +} +#endif + #if false // TODO: Process for use in SkipUI diff --git a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift index 5f488da7..dcabcd1d 100644 --- a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift +++ b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift @@ -489,9 +489,39 @@ extension EnvironmentValues { set { setBuiltinValue(key: "_listStyle", value: newValue, defaultValue: { nil }) } } - public var _materialColorScheme: (@Composable (androidx.compose.material3.ColorScheme, Bool) -> androidx.compose.material3.ColorScheme)? { - get { builtinValue(key: "_materialColorScheme", defaultValue: { nil }) as! (@Composable (androidx.compose.material3.ColorScheme, Bool) -> androidx.compose.material3.ColorScheme)? } - set { setBuiltinValue(key: "_materialColorScheme", value: newValue, defaultValue: { nil }) } + var _material3BottomAppBar: (@Composable (Material3BottomAppBarOptions) -> Material3BottomAppBarOptions)? { + get { builtinValue(key: "_material3BottomAppBar", defaultValue: { nil }) as! (@Composable (Material3BottomAppBarOptions) -> Material3BottomAppBarOptions)? } + set { setBuiltinValue(key: "_material3BottomAppBar", value: newValue, defaultValue: { nil }) } + } + + var _material3Button: (@Composable (Material3ButtonOptions) -> Material3ButtonOptions)? { + get { builtinValue(key: "_material3Button", defaultValue: { nil }) as! (@Composable (Material3ButtonOptions) -> Material3ButtonOptions)? } + set { setBuiltinValue(key: "_material3Button", value: newValue, defaultValue: { nil }) } + } + + var _material3ColorScheme: (@Composable (androidx.compose.material3.ColorScheme, Bool) -> androidx.compose.material3.ColorScheme)? { + get { builtinValue(key: "_material3ColorScheme", defaultValue: { nil }) as! (@Composable (androidx.compose.material3.ColorScheme, Bool) -> androidx.compose.material3.ColorScheme)? } + set { setBuiltinValue(key: "_material3ColorScheme", value: newValue, defaultValue: { nil }) } + } + + var _material3NavigationBar: (@Composable (Material3NavigationBarOptions) -> Material3NavigationBarOptions)? { + get { builtinValue(key: "_material3NavigationBar", defaultValue: { nil }) as! (@Composable (Material3NavigationBarOptions) -> Material3NavigationBarOptions)? } + set { setBuiltinValue(key: "_material3NavigationBar", value: newValue, defaultValue: { nil }) } + } + + var _material3Text: (@Composable (Material3TextOptions) -> Material3TextOptions)? { + get { builtinValue(key: "_material3Text", defaultValue: { nil }) as! (@Composable (Material3TextOptions) -> Material3TextOptions)? } + set { setBuiltinValue(key: "_material3Text", value: newValue, defaultValue: { nil }) } + } + + var _material3TextField: (@Composable (Material3TextFieldOptions) -> Material3TextFieldOptions)? { + get { builtinValue(key: "_material3TextField", defaultValue: { nil }) as! (@Composable (Material3TextFieldOptions) -> Material3TextFieldOptions)? } + set { setBuiltinValue(key: "_material3TextField", value: newValue, defaultValue: { nil }) } + } + + var _material3TopAppBar: (@Composable (Material3TopAppBarOptions) -> Material3TopAppBarOptions)? { + get { builtinValue(key: "_material3TopAppBar", defaultValue: { nil }) as! (@Composable (Material3TopAppBarOptions) -> Material3TopAppBarOptions)? } + set { setBuiltinValue(key: "_material3TopAppBar", value: newValue, defaultValue: { nil }) } } var _onSubmitState: OnSubmitState? { diff --git a/Sources/SkipUI/SkipUI/Text/Text.swift b/Sources/SkipUI/SkipUI/Text/Text.swift index 18aff491..cbe563db 100644 --- a/Sources/SkipUI/SkipUI/Text/Text.swift +++ b/Sources/SkipUI/SkipUI/Text/Text.swift @@ -25,6 +25,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.UrlAnnotation import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -33,6 +34,8 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit import skip.foundation.LocalizedStringResource import skip.foundation.Bundle import skip.foundation.Locale @@ -344,6 +347,7 @@ struct _Text: View, Equatable { } let animatable = style.asAnimatable(context: context) + var options: Material3TextOptions if let locnode { let layoutResult = remember { mutableStateOf(nil) } let isPlaceholder = redaction.contains(RedactionReasons.placeholder) @@ -366,7 +370,7 @@ struct _Text: View, Equatable { } } } - androidx.compose.material3.Text(text: annotatedText, modifier: modifier, color: textColor ?? androidx.compose.ui.graphics.Color.Unspecified, maxLines: maxLines, style: animatable.value, textDecoration: textDecoration, textAlign: textAlign, onTextLayout: { layoutResult.value = $0 }) + options = Material3TextOptions(annotatedText: annotatedText, modifier: modifier, color: textColor ?? androidx.compose.ui.graphics.Color.Unspecified, maxLines: maxLines, style: animatable.value, textDecoration: textDecoration, textAlign: textAlign, onTextLayout: { layoutResult.value = $0 }) } else { var text: String if let interpolations { @@ -379,7 +383,15 @@ struct _Text: View, Equatable { } else if isLowercased { text = text.lowercased() } - androidx.compose.material3.Text(text: text, modifier: context.modifier, color: textColor ?? androidx.compose.ui.graphics.Color.Unspecified, maxLines: maxLines, style: animatable.value, textDecoration: textDecoration, textAlign: textAlign) + options = Material3TextOptions(text: text, modifier: context.modifier, color: textColor ?? androidx.compose.ui.graphics.Color.Unspecified, maxLines: maxLines, style: animatable.value, textDecoration: textDecoration, textAlign: textAlign) + } + if let updateOptions = EnvironmentValues.shared._material3Text { + options = updateOptions(options) + } + if let annotatedText = options.annotatedText, let onTextLayout = options.onTextLayout { + androidx.compose.material3.Text(text: annotatedText, modifier: options.modifier, color: options.color, fontSize: options.fontSize, fontStyle: options.fontStyle, fontWeight: options.fontWeight, fontFamily: options.fontFamily, letterSpacing: options.letterSpacing, textDecoration: options.textDecoration, textAlign: options.textAlign, lineHeight: options.lineHeight, overflow: options.overflow, softWrap: options.softWrap, maxLines: options.maxLines, minLines: options.minLines, onTextLayout: onTextLayout, style: options.style) + } else { + androidx.compose.material3.Text(text: options.text ?? "", modifier: options.modifier, color: options.color, fontSize: options.fontSize, fontStyle: options.fontStyle, fontWeight: options.fontWeight, fontFamily: options.fontFamily, letterSpacing: options.letterSpacing, textDecoration: options.textDecoration, textAlign: options.textAlign, lineHeight: options.lineHeight, overflow: options.overflow, softWrap: options.softWrap, maxLines: options.maxLines, minLines: options.minLines, onTextLayout: options.onTextLayout, style: options.style) } } @@ -708,7 +720,60 @@ extension View { public func unredacted() -> some View { return self } + + #if SKIP + /// Compose text field customization. + public func material3Text(_ options: @Composable (Material3TextOptions) -> Material3TextOptions) -> View { + return environment(\._material3Text, options) + } + #endif +} + +#if SKIP +public struct Material3TextOptions { + public var text: String? = nil + public var annotatedText: AnnotatedString? = nil + public var modifier: Modifier = Modifier + public var color: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Unspecified + public var fontSize: TextUnit = TextUnit.Unspecified + public var fontStyle: FontStyle? = nil + public var fontWeight: FontWeight? = nil + public var fontFamily: FontFamily? = nil + public var letterSpacing: TextUnit = TextUnit.Unspecified + public var textDecoration: TextDecoration? = nil + public var textAlign: TextAlign? = nil + public var lineHeight: TextUnit = TextUnit.Unspecified + public var overflow: TextOverflow = TextOverflow.Clip + public var softWrap = true + public var maxLines = Int.max + public var minLines = 1 + public var onTextLayout: ((TextLayoutResult) -> Void)? = nil + public var style: TextStyle + + public func copy( + text: String? = self.text, + annotatedText: AnnotatedString? = self.annotatedText, + modifier: Modifier = self.modifier, + color: androidx.compose.ui.graphics.Color = self.color, + fontSize: TextUnit = self.fontSize, + fontStyle: FontStyle? = self.fontStyle, + fontWeight: FontWeight? = self.fontWeight, + fontFamily: FontFamily? = self.fontFamily, + letterSpacing: TextUnit = self.letterSpacing, + textDecoration: TextDecoration? = self.textDecoration, + textAlign: TextAlign? = self.textAlign, + lineHeight: TextUnit = self.lineHeight, + overflow: TextOverflow = self.overflow, + softWrap: Bool = self.softWrap, + maxLines: Int = self.maxLines, + minLines: Int = self.minLines, + onTextLayout: ((TextLayoutResult) -> Void)? = self.onTextLayout, + style: TextStyle = self.style + ) -> Material3TextOptions { + return Material3TextOptions(text: text, annotatedText: annotatedText, modifier: modifier, color: color, fontSize: fontSize, fontStyle: fontStyle, fontWeight: fontWeight, fontFamily: fontFamily, letterSpacing: letterSpacing, textDecoration: textDecoration, textAlign: textAlign, lineHeight: lineHeight, overflow: overflow, softWrap: softWrap, maxLines: maxLines, minLines: minLines, onTextLayout: onTextLayout, style: style) + } } +#endif public struct RedactionReasons : OptionSet, Sendable { public let rawValue: Int diff --git a/Sources/SkipUI/SkipUI/Text/TextField.swift b/Sources/SkipUI/SkipUI/Text/TextField.swift index b23b7fa8..da7a312f 100644 --- a/Sources/SkipUI/SkipUI/Text/TextField.swift +++ b/Sources/SkipUI/SkipUI/Text/TextField.swift @@ -5,16 +5,20 @@ // as published by the Free Software Foundation https://fsf.org #if SKIP +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.ContentAlpha import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.TextFieldColors import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation @@ -74,11 +78,15 @@ public struct TextField : View { let keyboardActions = KeyboardActions(EnvironmentValues.shared._onSubmitState, LocalFocusManager.current) let colors = Self.colors(context: context) let visualTransformation = isSecure ? PasswordVisualTransformation() : VisualTransformation.None - OutlinedTextField(value: text.wrappedValue, onValueChange: { + var options = Material3TextFieldOptions(value: text.wrappedValue, onValueChange: { text.wrappedValue = $0 }, placeholder: { Self.Placeholder(prompt: prompt ?? label, context: contentContext) - }, modifier: context.modifier.fillWidth(), enabled: EnvironmentValues.shared.isEnabled, singleLine: true, keyboardOptions: keyboardOptions, keyboardActions: keyboardActions, colors: colors, visualTransformation: visualTransformation) + }, modifier: context.modifier.fillWidth(), textStyle: LocalTextStyle.current, enabled: EnvironmentValues.shared.isEnabled, singleLine: true, visualTransformation: visualTransformation, keyboardOptions: keyboardOptions, keyboardActions: keyboardActions, maxLines: 1, shape: OutlinedTextFieldDefaults.shape, colors: colors) + if let updateOptions = EnvironmentValues.shared._material3TextField { + options = updateOptions(options) + } + OutlinedTextField(value: options.value, onValueChange: options.onValueChange, modifier: options.modifier, enabled: options.enabled, readOnly: options.readOnly, textStyle: options.textStyle, label: options.label, placeholder: options.placeholder, leadingIcon: options.leadingIcon, trailingIcon: options.trailingIcon, prefix: options.prefix, suffix: options.suffix, supportingText: options.supportingText, isError: options.isError, visualTransformation: options.visualTransformation, keyboardOptions: options.keyboardOptions, keyboardActions: options.keyboardActions, singleLine: options.singleLine, maxLines: options.maxLines, minLines: options.minLines, interactionSource: options.interactionSource, shape: options.shape, colors: options.colors) } @Composable static func textColor(enabled: Bool, context: ComposeContext) -> androidx.compose.ui.graphics.Color { @@ -234,9 +242,70 @@ extension View { } } } + + /// Compose text field customization. + public func material3TextField(_ options: @Composable (Material3TextFieldOptions) -> Material3TextFieldOptions) -> View { + return environment(\._material3TextField, options) + } #endif } +#if SKIP +public struct Material3TextFieldOptions { + public var value: String + public var onValueChange: (String) -> Void + public var modifier: Modifier = Modifier + public var enabled = true + public var readOnly = false + public var textStyle: TextStyle + public var label: (@Composable () -> Void)? = nil + public var placeholder: (@Composable () -> Void)? = nil + public var leadingIcon: (@Composable () -> Void)? = nil + public var trailingIcon: (@Composable () -> Void)? = nil + public var prefix: (@Composable () -> Void)? = nil + public var suffix: (@Composable () -> Void)? = nil + public var supportingText: (@Composable () -> Void)? = nil + public var isError = false + public var visualTransformation: VisualTransformation = VisualTransformation.None + public var keyboardOptions: KeyboardOptions = KeyboardOptions.Default + public var keyboardActions: KeyboardActions = KeyboardActions.Default + public var singleLine = false + public var maxLines = Int.max + public var minLines = 1 + public var interactionSource: MutableInteractionSource? = nil + public var shape: androidx.compose.ui.graphics.Shape + public var colors: TextFieldColors + + public func copy( + value: String = self.value, + onValueChange: (String) -> Void = self.onValueChange, + modifier: Modifier = self.modifier, + enabled: Bool = self.enabled, + readOnly: Bool = self.readOnly, + textStyle: TextStyle = self.textStyle, + label: (@Composable () -> Void)? = self.label, + placeholder: (@Composable () -> Void)? = self.placeholder, + leadingIcon: (@Composable () -> Void)? = self.leadingIcon, + trailingIcon: (@Composable () -> Void)? = self.trailingIcon, + prefix: (@Composable () -> Void)? = self.prefix, + suffix: (@Composable () -> Void)? = self.suffix, + supportingText: (@Composable () -> Void)? = self.supportingText, + isError: Bool = self.isError, + visualTransformation: VisualTransformation = self.visualTransformation, + keyboardOptions: KeyboardOptions = self.keyboardOptions, + keyboardActions: KeyboardActions = self.keyboardActions, + singleLine: Bool = self.singleLine, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = self.minLines, + interactionSource: MutableInteractionSource? = self.interactionSource, + shape: androidx.compose.ui.graphics.Shape = self.shape, + colors: TextFieldColors = self.colors + ) -> Material3TextFieldOptions { + return Material3TextFieldOptions(value: value, onValueChange: onValueChange, modifier: modifier, enabled: enabled, readOnly: readOnly, textStyle: textStyle, label: label, placeholder: placeholder, leadingIcon: leadingIcon, trailingIcon: trailingIcon, prefix: prefix, suffix: suffix, supportingText: supportingText, isError: isError, visualTransformation: visualTransformation, keyboardOptions: keyboardOptions, keyboardActions: keyboardActions, singleLine: singleLine, maxLines: maxLines, minLines: minLines, interactionSource: interactionSource, shape: shape, colors: colors) + } +} +#endif + /// State for `onSubmit` actions. struct OnSubmitState { let actions: [(SubmitTriggers, () -> Void)]