diff --git a/Sources/SkipUI/SkipUI/Containers/Navigation.swift b/Sources/SkipUI/SkipUI/Containers/Navigation.swift index f71a2d96..b81532fb 100644 --- a/Sources/SkipUI/SkipUI/Containers/Navigation.swift +++ b/Sources/SkipUI/SkipUI/Containers/Navigation.swift @@ -169,33 +169,33 @@ public struct NavigationStack : View where Root: View { // compose the root view with a custom composer that will capture the destinations. Note that 'root' is just a reference to // the enclosing ComposeView, so a custom composer is the only way to receive a reference to our actual root view. // Have to use rememberSaveable for e.g. a nav stack in each tab - // SKIP INSERT: var destinations by rememberSaveable(stateSaver = context.stateSaver as Saver) { mutableStateOf(null) } - if destinations == nil { + let destinations = rememberSaveable(stateSaver: context.stateSaver as! Saver) { mutableStateOf(nil) } + if destinations.value == nil { root.Compose(context: context.content(composer: { view, context in - destinations = (view as? NavigationDestinationView)?.destinations ?? [:] + destinations.value = (view as? NavigationDestinationView)?.destinations ?? [:] })) } let navController = rememberNavController() - // SKIP INSERT: val navigator by rememberSaveable(stateSaver = context.stateSaver as Saver) { mutableStateOf(Navigator(navController = navController, destinations = destinations ?: dictionaryOf())) } - navigator.navController = navController // May change on recompose - navigator.syncState() + let navigator = rememberSaveable(stateSaver: context.stateSaver as! Saver) { mutableStateOf(Navigator(navController: navController, destinations: destinations.value ?? dictionaryOf())) } + navigator.value.navController = navController // May change on recompose + navigator.value.syncState() - // SKIP INSERT: val providedNavigator = LocalNavigator provides navigator + // SKIP INSERT: val providedNavigator = LocalNavigator provides navigator.value CompositionLocalProvider(providedNavigator) { NavHost(navController: navController, startDestination: Navigator.rootRoute, modifier: context.modifier) { composable(route: Navigator.rootRoute) { entry in - if let state = navigator.state(for: entry) { + if let state = navigator.value.state(for: entry) { let entryContext = context.content(stateSaver: state.stateSaver) ComposeEntry(navController: navController, isRoot: true, context: entryContext) { context in root.Compose(context: context) } } } - if let destinations { + if let destinations = destinations.value { for (targetType, viewBuilder) in destinations { composable(route: Navigator.route(for: targetType, valueString: "{identifier}"), arguments: listOf(navArgument("identifier") { type = NavType.StringType })) { entry in - if let state = navigator.state(for: entry), let targetValue = state.targetValue { + if let state = navigator.value.state(for: entry), let targetValue = state.targetValue { let entryContext = context.content(stateSaver: state.stateSaver) ComposeEntry(navController: navController, isRoot: false, context: entryContext) { context in viewBuilder(targetValue).Compose(context: context) @@ -210,10 +210,10 @@ public struct NavigationStack : View where Root: View { // SKIP INSERT: @OptIn(ExperimentalMaterial3Api::class) @Composable private func ComposeEntry(navController: NavHostController, isRoot: Bool, context: ComposeContext, content: @Composable (ComposeContext) -> Void) { - // SKIP INSERT: var preferenceUpdates by remember { mutableStateOf(0) } - let _ = preferenceUpdates // Read so that it can trigger recompose on change + let preferenceUpdates = remember { mutableStateOf(0) } + let _ = preferenceUpdates.value // Read so that it can trigger recompose on change - // SKIP INSERT: var title by rememberSaveable(stateSaver = context.stateSaver as Saver) { mutableStateOf(NavigationTitlePreferenceKey.defaultValue) } + let title = rememberSaveable(stateSaver: context.stateSaver as! Saver) { mutableStateOf(NavigationTitlePreferenceKey.defaultValue) } // We place the top bar scaffold within each entry rather than at the navigation controller level. There isn't a fluid animation // between navigation bar states on Android, and it is simpler to only hoist navigation bar preferences to this level @@ -227,7 +227,7 @@ public struct NavigationStack : View where Root: View { titleContentColor: MaterialTheme.colorScheme.onSurface ), title: { - androidx.compose.material3.Text(title, maxLines: 1, overflow: TextOverflow.Ellipsis) + androidx.compose.material3.Text(title.value, maxLines: 1, overflow: TextOverflow.Ellipsis) }, navigationIcon: { if !isRoot { @@ -240,7 +240,7 @@ public struct NavigationStack : View where Root: View { ) } ) { padding in - let titlePreference = Preference(key: NavigationTitlePreferenceKey.self, update: { title = $0 }, didChange: { preferenceUpdates += 1 }) + let titlePreference = Preference(key: NavigationTitlePreferenceKey.self, update: { title.value = $0 }, didChange: { preferenceUpdates.value += 1 }) PreferenceValues.shared.collectPreferences([titlePreference]) { Box(modifier: Modifier.padding(padding).fillMaxSize().then(context.modifier), contentAlignment: androidx.compose.ui.Alignment.Center) { content(context.content()) diff --git a/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift b/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift index 34f25b57..98bbc7bf 100644 --- a/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift +++ b/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift @@ -114,9 +114,9 @@ extension View { public func preference(key: Any.Type, value: Any) -> some View { #if SKIP return ComposeModifierView(contentView: self) { view, context in - // SKIP INSERT: var pvalue by remember { mutableStateOf(null) } - PreferenceValues.shared.preference(key: key)?.reduce(savedValue: pvalue, newValue: value) - pvalue = value + let pvalue = remember { mutableStateOf(nil) } + PreferenceValues.shared.preference(key: key)?.reduce(savedValue: pvalue.value, newValue: value) + pvalue.value = value view.Compose(context: context) } #else diff --git a/Sources/SkipUI/SkipUI/Environment/ViewExtensions.swift b/Sources/SkipUI/SkipUI/Environment/ViewExtensions.swift index 4a74bc33..14855363 100644 --- a/Sources/SkipUI/SkipUI/Environment/ViewExtensions.swift +++ b/Sources/SkipUI/SkipUI/Environment/ViewExtensions.swift @@ -9853,133 +9853,6 @@ extension View { } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension View { - - /// Adds an asynchronous task to perform before this view appears. - /// - /// Use this modifier to perform an asynchronous task with a lifetime that - /// matches that of the modified view. If the task doesn't finish - /// before SkipUI removes the view or the view changes identity, SkipUI - /// cancels the task. - /// - /// Use the `await` keyword inside the task to - /// wait for an asynchronous call to complete, or to wait on the values of - /// an - /// instance. For example, you can modify a ``Text`` view to start a task - /// that loads content from a remote resource: - /// - /// let url = URL(string: "https://example.com")! - /// @State private var message = "Loading..." - /// - /// var body: some View { - /// Text(message) - /// .task { - /// do { - /// var receivedLines = [String]() - /// for try await line in url.lines { - /// receivedLines.append(line) - /// message = "Received \(receivedLines.count) lines" - /// } - /// } catch { - /// message = "Failed to load" - /// } - /// } - /// } - /// - /// This example uses the - /// method to get the content stored at the specified - /// as an - /// asynchronous sequence of strings. When each new line arrives, the body - /// of the `for`-`await`-`in` loop stores the line in an array of strings - /// and updates the content of the text view to report the latest line - /// count. - /// - /// - Parameters: - /// - priority: The task priority to use when creating the asynchronous - /// task. The default priority is - /// . - /// - action: A closure that SkipUI calls as an asynchronous task - /// before the view appears. SkipUI will automatically cancel the task - /// at some point after the view disappears before the action completes. - /// - /// - /// - Returns: A view that runs the specified action asynchronously before - /// the view appears. - public func task(priority: TaskPriority = .userInitiated, _ action: @escaping @Sendable () async -> Void) -> some View { return stubView() } - - - /// Adds a task to perform before this view appears or when a specified - /// value changes. - /// - /// This method behaves like ``View/task(priority:_:)``, except that it also - /// cancels and recreates the task when a specified value changes. To detect - /// a change, the modifier tests whether a new value for the `id` parameter - /// equals the previous value. For this to work, - /// the value's type must conform to the - /// protocol. - /// - /// For example, if you define an equatable `Server` type that posts custom - /// notifications whenever its state changes --- for example, from _signed - /// out_ to _signed in_ --- you can use the task modifier to update - /// the contents of a ``Text`` view to reflect the state of the - /// currently selected server: - /// - /// Text(status ?? "Signed Out") - /// .task(id: server) { - /// let sequence = NotificationCenter.default.notifications( - /// named: .didChangeStatus, - /// object: server) - /// for try await notification in sequence { - /// status = notification.userInfo["status"] as? String - /// } - /// } - /// - /// This example uses the - /// method to wait indefinitely for an asynchronous sequence of - /// notifications, given by an - /// instance. - /// - /// Elsewhere, the server defines a custom `didUpdateStatus` notification: - /// - /// extension NSNotification.Name { - /// static var didUpdateStatus: NSNotification.Name { - /// NSNotification.Name("didUpdateStatus") - /// } - /// } - /// - /// The server then posts a notification of this type whenever its status - /// changes, like after the user signs in: - /// - /// let notification = Notification( - /// name: .didUpdateStatus, - /// object: self, - /// userInfo: ["status": "Signed In"]) - /// NotificationCenter.default.post(notification) - /// - /// The task attached to the ``Text`` view gets and displays the status - /// value from the notification's user information dictionary. When the user - /// chooses a different server, SkipUI cancels the task and creates a new - /// one, which then starts waiting for notifications from the new server. - /// - /// - Parameters: - /// - id: The value to observe for changes. The value must conform - /// to the - /// protocol. - /// - priority: The task priority to use when creating the asynchronous - /// task. The default priority is - /// . - /// - action: A closure that SkipUI calls as an asynchronous task - /// before the view appears. SkipUI can automatically cancel the task - /// after the view disappears before the action completes. If the - /// `id` value changes, SkipUI cancels and restarts the task. - /// - /// - Returns: A view that runs the specified action asynchronously before - /// the view appears, or restarts the task with the `id` value changes. - public func task(id value: T, priority: TaskPriority = .userInitiated, _ action: @escaping @Sendable () async -> Void) -> some View where T : Equatable { return stubView() } - -} - @available(iOS 16.0, macOS 13.0, watchOS 7.0, *) @available(tvOS, unavailable) extension View { diff --git a/Sources/SkipUI/SkipUI/Text/LocalizedStringKey.swift b/Sources/SkipUI/SkipUI/Text/LocalizedStringKey.swift index ad06d0ec..f2785105 100644 --- a/Sources/SkipUI/SkipUI/Text/LocalizedStringKey.swift +++ b/Sources/SkipUI/SkipUI/Text/LocalizedStringKey.swift @@ -14,81 +14,24 @@ import struct Foundation.DateComponents import struct Foundation.DateInterval import struct Foundation.AttributedString import struct Foundation.LocalizedStringResource -#else #endif -/// The key used to look up an entry in a strings file or strings dictionary -/// file. -/// -/// Initializers for several SkipUI types -- such as ``Text``, ``Toggle``, -/// ``Picker`` and others -- implicitly look up a localized string when you -/// provide a string literal. When you use the initializer `Text("Hello")`, -/// SkipUI creates a `LocalizedStringKey` for you and uses that to look up a -/// localization of the `Hello` string. This works because `LocalizedStringKey` -/// conforms to -/// . -/// -/// Types whose initializers take a `LocalizedStringKey` usually have -/// a corresponding initializer that accepts a parameter that conforms to -/// . Passing -/// a `String` variable to these initializers avoids localization, which is -/// usually appropriate when the variable contains a user-provided value. -/// -/// As a general rule, use a string literal argument when you want -/// localization, and a string variable argument when you don't. In the case -/// where you want to localize the value of a string variable, use the string to -/// create a new `LocalizedStringKey` instance. -/// -/// The following example shows how to create ``Text`` instances both -/// with and without localization. The title parameter provided to the -/// ``Section`` is a literal string, so SkipUI creates a -/// `LocalizedStringKey` for it. However, the string entries in the -/// `messageStore.today` array are `String` variables, so the ``Text`` views -/// in the list use the string values verbatim. -/// -/// List { -/// Section(header: Text("Today")) { -/// ForEach(messageStore.today) { message in -/// Text(message.title) -/// } -/// } -/// } -/// -/// If the app is localized into Japanese with the following -/// translation of its `Localizable.strings` file: -/// -/// ```other -/// "Today" = "今日"; -/// ``` -/// -/// When run in Japanese, the example produces a -/// list like the following, localizing "Today" for the section header, but not -/// the list items. -/// -/// ![A list with a single section header displayed in Japanese. -/// The items in the list are all in English: New for Monday, Account update, -/// and Server -/// maintenance.](SkipUI-LocalizedStringKey-Today-List-Japanese.png) -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -@frozen public struct LocalizedStringKey : Equatable { +public struct LocalizedStringKey : Equatable { public var value: String - /// Creates a localized string key from the given string value. - /// - /// - Parameter value: The string to use as a localization key. public init(_ value: String) { self.value = value } - /// Creates a localized string key from the given string literal. - /// - /// - Parameter value: The string literal to use as a localization key. public init(stringLiteral value: String) { self.value = value } } #if !SKIP + +// TODO: Process for use in SkipUI + extension LocalizedStringKey : ExpressibleByStringInterpolation { /// Creates a localized string key from the given string interpolation. diff --git a/Sources/SkipUI/SkipUI/Text/Text.swift b/Sources/SkipUI/SkipUI/Text/Text.swift index 6309de98..e1ab6b29 100644 --- a/Sources/SkipUI/SkipUI/Text/Text.swift +++ b/Sources/SkipUI/SkipUI/Text/Text.swift @@ -20,9 +20,13 @@ public struct Text: View, Equatable, Sendable { self.text = verbatim } - public init(_ text: String, bundle: Bundle? = nil, comment: StaticString? = nil) { + public init(_ text: String) { self.text = text } + + public init(_ key: LocalizedStringKey, bundle: Bundle? = nil, comment: StaticString? = nil) { + self.text = key.value + } public init(_ resource: LocalizedStringResource) { self.text = resource.key diff --git a/Sources/SkipUI/SkipUI/View/View.swift b/Sources/SkipUI/SkipUI/View/View.swift index 3bc6b486..c7f355df 100644 --- a/Sources/SkipUI/SkipUI/View/View.swift +++ b/Sources/SkipUI/SkipUI/View/View.swift @@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.rotate @@ -186,7 +188,7 @@ extension View { $0.modifier = $0.modifier.rotate(Float(angle.degrees)) } #else - return stubView() + return self #endif } @@ -195,7 +197,7 @@ extension View { #if SKIP fatalError() #else - return stubView() + return self #endif } @@ -223,7 +225,7 @@ extension View { $0.modifier = $0.modifier.scale(scaleX: Float(x), scaleY: Float(y)) } #else - return stubView() + return self #endif } @@ -231,6 +233,24 @@ extension View { public func scaleEffect(x: CGFloat = 1.0, y: CGFloat = 1.0, anchor: UnitPoint) -> some View { return scaleEffect(x: x, y: y) } + + public func task(priority: TaskPriority = .userInitiated, _ action: @escaping () async -> Void) -> some View { + return task(id: 0, priority: priority, action) + } + + public func task(id value: Any, priority: TaskPriority = .userInitiated, _ action: @escaping () async -> Void) -> some View { + #if SKIP + return ComposeModifierView(contentView: self) { view, context in + let handler = rememberUpdatedState(action) + LaunchedEffect(value) { + handler.value() + } + view.Compose(context: context) + } + #else + return self + #endif + } } #if !SKIP diff --git a/Tests/SkipUITests/SkipUITests.swift b/Tests/SkipUITests/SkipUITests.swift index 018015e0..10c05c33 100644 --- a/Tests/SkipUITests/SkipUITests.swift +++ b/Tests/SkipUITests/SkipUITests.swift @@ -474,13 +474,13 @@ final class SkipUITests: XCTestCase { func testPressButton() { #if SKIP composeRule.setContent { - // SKIP INSERT: var counter by remember { mutableStateOf(0) } + let counter = remember { mutableStateOf(0) } androidx.compose.material3.Text( - text: counter.toString(), + text: counter.value.toString(), modifier: Modifier.testTag("Counter") ) - androidx.compose.material3.Button(onClick = { counter++ }) { + androidx.compose.material3.Button(onClick = { counter.value++ }) { androidx.compose.material3.Text("Increment") } }