From f0ab72ed876346a9bc52b5b708ed1c56336541f1 Mon Sep 17 00:00:00 2001 From: Abe White Date: Fri, 22 Sep 2023 23:33:20 -0500 Subject: [PATCH] Put preferences system in place. Implement navigation bar and .navigationTitle --- .../SkipUI/Compose/ComposeContext.swift | 9 +- .../SkipUI/SkipUI/Containers/Navigation.swift | 455 ++++-------------- .../SkipUI/Environment/Environment.swift | 69 +-- .../SkipUI/Environment/PreferenceKey.swift | 101 ++-- 4 files changed, 171 insertions(+), 463 deletions(-) diff --git a/Sources/SkipUI/SkipUI/Compose/ComposeContext.swift b/Sources/SkipUI/SkipUI/Compose/ComposeContext.swift index f9e00edf..3f1b4b2e 100644 --- a/Sources/SkipUI/SkipUI/Compose/ComposeContext.swift +++ b/Sources/SkipUI/SkipUI/Compose/ComposeContext.swift @@ -9,19 +9,20 @@ import androidx.compose.ui.Modifier /// Context to provide modifiers, parent, etc to composables. public struct ComposeContext { - /// Mechanism for a parent view to change how a child view is composed. - public var composer: (@Composable (inout View, ComposeContext) -> Void)? - /// Modifiers to apply. public var modifier: Modifier = Modifier /// Use in conjunction with `rememberSaveable` to store view state. public var stateSaver: Saver = ComposeStateSaver() + /// Mechanism for a parent view to change how a child view is composed. + public var composer: (@Composable (inout View, ComposeContext) -> Void)? + /// The context to pass to child content of a container view. - public func content(modifier: Modifier = Modifier, composer: (@Composable (inout View, ComposeContext) -> Void)? = nil) -> ComposeContext { + public func content(modifier: Modifier = Modifier, stateSaver: Saver? = nil, composer: (@Composable (inout View, ComposeContext) -> Void)? = nil) -> ComposeContext { var context = self context.modifier = modifier + context.stateSaver = stateSaver ?? self.stateSaver context.composer = composer return context } diff --git a/Sources/SkipUI/SkipUI/Containers/Navigation.swift b/Sources/SkipUI/SkipUI/Containers/Navigation.swift index 1abfbd29..f71a2d96 100644 --- a/Sources/SkipUI/SkipUI/Containers/Navigation.swift +++ b/Sources/SkipUI/SkipUI/Containers/Navigation.swift @@ -164,7 +164,6 @@ public struct NavigationStack : View where Root: View { } #if SKIP - // SKIP INSERT: @OptIn(ExperimentalMaterial3Api::class) @Composable public override func ComposeContent(context: ComposeContext) { // Check to see if we've initialized our destinations from our root view's .navigationDestination modifiers. If we haven't, // compose the root view with a custom composer that will capture the destinations. Note that 'root' is just a reference to @@ -184,43 +183,23 @@ public struct NavigationStack : View where Root: View { // SKIP INSERT: val providedNavigator = LocalNavigator provides navigator CompositionLocalProvider(providedNavigator) { - let scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) - Scaffold( - modifier: Modifier.nestedScroll(scrollBehavior.nestedScrollConnection).then(context.modifier), - topBar: { - MediumTopAppBar( - colors: TopAppBarDefaults.topAppBarColors( - containerColor: Color.systemBackground.colorImpl(), - titleContentColor: MaterialTheme.colorScheme.onSurface - ), - title: { -// androidx.compose.material3.Text("Cities", maxLines: 1, overflow: TextOverflow.Ellipsis) - }, - navigationIcon: NavigationIconComposable(navController: navController), - scrollBehavior: scrollBehavior - ) - } - ) { padding in - ComposeNavHost(navController: navController, navigator: navigator, destinations: destinations, context: context.content(modifier: Modifier.padding(padding))) - } - } - } - - @Composable private func ComposeNavHost(navController: NavHostController, navigator: Navigator, destinations: NavigationDestinations?, context: ComposeContext) { - NavHost(navController: navController, startDestination: Navigator.rootRoute, modifier: context.modifier) { - composable(route: Navigator.rootRoute) { entry in - if let state = navigator.state(for: entry) { - Box(modifier: Modifier.fillMaxSize(), contentAlignment: androidx.compose.ui.Alignment.Center) { - root.Compose(context: ComposeContext(stateSaver: state.stateSaver)) + NavHost(navController: navController, startDestination: Navigator.rootRoute, modifier: context.modifier) { + composable(route: Navigator.rootRoute) { entry in + if let state = navigator.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 { - 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 { - Box(modifier: Modifier.fillMaxSize(), contentAlignment: androidx.compose.ui.Alignment.Center) { - viewBuilder(targetValue).Compose(context: ComposeContext(stateSaver: state.stateSaver)) + if let destinations { + 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 { + let entryContext = context.content(stateSaver: state.stateSaver) + ComposeEntry(navController: navController, isRoot: false, context: entryContext) { context in + viewBuilder(targetValue).Compose(context: context) + } } } } @@ -229,14 +208,43 @@ public struct NavigationStack : View where Root: View { } } - @Composable private func NavigationIconComposable(navController: NavHostController) -> (@Composable () -> Void) { - // SKIP INSERT: val entryList by navController.currentBackStack.collectAsState() - guard entryList.size > 2 else { // NavGraph + root entry - return {} - } - return { - IconButton(onClick: { navController.popBackStack() }) { - Icon(imageVector: Icons.Filled.ArrowBack, contentDescription: "Back") + // 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 + + // SKIP INSERT: var title by 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 + let scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) + Scaffold( + modifier: Modifier.nestedScroll(scrollBehavior.nestedScrollConnection).then(context.modifier), + topBar: { + MediumTopAppBar( + colors: TopAppBarDefaults.topAppBarColors( + containerColor: Color.systemBackground.colorImpl(), + titleContentColor: MaterialTheme.colorScheme.onSurface + ), + title: { + androidx.compose.material3.Text(title, maxLines: 1, overflow: TextOverflow.Ellipsis) + }, + navigationIcon: { + if !isRoot { + IconButton(onClick: { navController.popBackStack() }) { + Icon(imageVector: Icons.Filled.ArrowBack, contentDescription: "Back") + } + } + }, + scrollBehavior: scrollBehavior + ) + } + ) { padding in + let titlePreference = Preference(key: NavigationTitlePreferenceKey.self, update: { title = $0 }, didChange: { preferenceUpdates += 1 }) + PreferenceValues.shared.collectPreferences([titlePreference]) { + Box(modifier: Modifier.padding(padding).fillMaxSize().then(context.modifier), contentAlignment: androidx.compose.ui.Alignment.Center) { + content(context.content()) + } } } } @@ -260,6 +268,24 @@ extension View { public func navigationDestination(isPresented: Binding, @ViewBuilder destination: () -> V) -> some View where V : View { return self } + + public func navigationTitle(_ title: Text) -> some View { + return navigationTitle(title.text) + } + + public func navigationTitle(_ title: String) -> some View { + #if SKIP + // SKIP REPLACE: return preference(NavigationTitlePreferenceKey::class, title) + return preference(key: NavigationTitlePreferenceKey.self, value: title) + #else + return self + #endif + } + + @available(*, unavailable) + public func navigationTitle(_ title: Binding) -> some View { + return self + } } #if SKIP @@ -284,6 +310,18 @@ struct NavigationDestinationView: View { view.Compose(context: context) } } + +struct NavigationTitlePreferenceKey: PreferenceKey { + typealias Value = String + + // SKIP DECLARE: companion object: PreferenceKeyCompanion + class Companion: PreferenceKeyCompanion { + let defaultValue = "" + func reduce(value: inout String, nextValue: () -> String) { + value = nextValue() + } + } +} #endif public struct NavigationLink : View, ListItemAdapting { @@ -1390,257 +1428,7 @@ extension View { } -@available(iOS 13.0, macOS 13.0, tvOS 13.0, watchOS 6.0, *) extension View { - - /// Hides the navigation bar for this view. - /// - /// Use `navigationBarHidden(_:)` to hide the navigation bar. This modifier - /// only takes effect when this view is inside of and visible within a - /// ``NavigationView``. - /// - /// - Parameter hidden: A Boolean value that indicates whether to hide the - /// navigation bar. - @available(iOS, introduced: 13.0, deprecated: 100000.0, message: "Use toolbar(.hidden)") - @available(macOS, unavailable) - @available(tvOS, introduced: 13.0, deprecated: 100000.0, message: "Use toolbar(.hidden)") - @available(watchOS, introduced: 6.0, deprecated: 100000.0, message: "Use toolbar(.hidden)") - @available(xrOS, introduced: 1.0, deprecated: 100000.0, message: "Use toolbar(.hidden)") - public func navigationBarHidden(_ hidden: Bool) -> some View { return stubView() } - - - /// Sets the title in the navigation bar for this view. - /// - /// Use `navigationBarTitle(_:)` to set the title of the navigation bar. - /// This modifier only takes effect when this view is inside of and visible - /// within a ``NavigationView``. - /// - /// The example below shows setting the title of the navigation bar using a - /// ``Text`` view: - /// - /// struct FlavorView: View { - /// let items = ["Chocolate", "Vanilla", "Strawberry", "Mint Chip", - /// "Pistachio"] - /// var body: some View { - /// NavigationView { - /// List(items, id: \.self) { - /// Text($0) - /// } - /// .navigationBarTitle(Text("Today's Flavors")) - /// } - /// } - /// } - /// - /// ![A screenshot showing the title of a navigation bar configured using a - /// text view.](SkipUI-navigationBarTitle-Text.png) - /// - /// - Parameter title: A description of this view to display in the - /// navigation bar. - @available(iOS, introduced: 13.0, deprecated: 100000.0, renamed: "navigationTitle(_:)") - @available(macOS, unavailable) - @available(tvOS, introduced: 13.0, deprecated: 100000.0, renamed: "navigationTitle(_:)") - @available(watchOS, introduced: 6.0, deprecated: 100000.0, renamed: "navigationTitle(_:)") - @available(xrOS, introduced: 1.0, deprecated: 100000.0, renamed: "navigationTitle(_:)") - public func navigationBarTitle(_ title: Text) -> some View { return stubView() } - - - /// Sets the title of this view's navigation bar with a localized string. - /// - /// Use `navigationBarTitle(_:)` to set the title of the navigation bar - /// using a ``LocalizedStringKey`` that will be used to search for a - /// matching localized string in the application's localizable strings - /// assets. - /// - /// This modifier only takes effect when this view is inside of and visible - /// within a ``NavigationView``. - /// - /// In the example below, a string constant is used to access a - /// ``LocalizedStringKey`` that will be resolved at run time to provide a - /// title for the navigation bar. If the localization key cannot be - /// resolved, the text of the key name will be used as the title text. - /// - /// struct FlavorView: View { - /// let items = ["Chocolate", "Vanilla", "Strawberry", "Mint Chip", - /// "Pistachio"] - /// var body: some View { - /// NavigationView { - /// List(items, id: \.self) { - /// Text($0) - /// } - /// .navigationBarTitle("Today's Flavors") - /// } - /// } - /// } - /// - /// - Parameter titleKey: A key to a localized description of this view to - /// display in the navigation bar. - @available(iOS, introduced: 13.0, deprecated: 100000.0, renamed: "navigationTitle(_:)") - @available(macOS, unavailable) - @available(tvOS, introduced: 13.0, deprecated: 100000.0, renamed: "navigationTitle(_:)") - @available(watchOS, introduced: 6.0, deprecated: 100000.0, renamed: "navigationTitle(_:)") - @available(xrOS, introduced: 1.0, deprecated: 100000.0, renamed: "navigationTitle(_:)") - public func navigationBarTitle(_ titleKey: LocalizedStringKey) -> some View { return stubView() } - - - /// Sets the title of this view's navigation bar with a string. - /// - /// Use `navigationBarTitle(_:)` to set the title of the navigation bar - /// using a `String`. This modifier only takes effect when this view is - /// inside of and visible within a ``NavigationView``. - /// - /// In the example below, text for the navigation bar title is provided - /// using a string: - /// - /// struct FlavorView: View { - /// let items = ["Chocolate", "Vanilla", "Strawberry", "Mint Chip", - /// "Pistachio"] - /// let text = "Today's Flavors" - /// var body: some View { - /// NavigationView { - /// List(items, id: \.self) { - /// Text($0) - /// } - /// .navigationBarTitle(text) - /// } - /// } - /// } - /// - /// - Parameter title: A title for this view to display in the navigation - /// bar. - @available(iOS, introduced: 13.0, deprecated: 100000.0, renamed: "navigationTitle(_:)") - @available(macOS, unavailable) - @available(tvOS, introduced: 13.0, deprecated: 100000.0, renamed: "navigationTitle(_:)") - @available(watchOS, introduced: 6.0, deprecated: 100000.0, renamed: "navigationTitle(_:)") - @available(xrOS, introduced: 1.0, deprecated: 100000.0, renamed: "navigationTitle(_:)") - public func navigationBarTitle(_ title: S) -> some View where S : StringProtocol { return stubView() } - - - /// Sets the title and display mode in the navigation bar for this view. - /// - /// Use `navigationBarTitle(_:displayMode:)` to set the title of the - /// navigation bar for this view and specify a display mode for the title - /// from one of the ``NavigationBarItem/TitleDisplayMode`` styles. This - /// modifier only takes effect when this view is inside of and visible - /// within a ``NavigationView``. - /// - /// In the example below, text for the navigation bar title is provided - /// using a ``Text`` view. The navigation bar title's - /// ``NavigationBarItem/TitleDisplayMode`` is set to `.inline` which places - /// the navigation bar title in the bounds of the navigation bar. - /// - /// struct FlavorView: View { - /// let items = ["Chocolate", "Vanilla", "Strawberry", "Mint Chip", - /// "Pistachio"] - /// var body: some View { - /// NavigationView { - /// List(items, id: \.self) { - /// Text($0) - /// } - /// .navigationBarTitle(Text("Today's Flavors", displayMode: .inline) - /// } - /// } - /// } - /// - /// - Parameters: - /// - title: A title for this view to display in the navigation bar. - /// - displayMode: The style to use for displaying the navigation bar title. - @available(iOS, introduced: 13.0, deprecated: 100000.0, message: "Use navigationTitle(_:) with navigationBarTitleDisplayMode(_:)") - @available(macOS, unavailable) - @available(tvOS, unavailable) - @available(watchOS, unavailable) - @available(xrOS, introduced: 1.0, deprecated: 100000.0, message: "Use navigationTitle(_:) with navigationBarTitleDisplayMode(_:)") - public func navigationBarTitle(_ title: Text, displayMode: NavigationBarItem.TitleDisplayMode) -> some View { return stubView() } - - - /// Sets the title and display mode in the navigation bar for this view. - /// - /// Use `navigationBarTitle(_:displayMode:)` to set the title of the - /// navigation bar for this view and specify a display mode for the title - /// from one of the ``NavigationBarItem/TitleDisplayMode`` styles. This - /// modifier only takes effect when this view is inside of and visible - /// within a ``NavigationView``. - /// - /// In the example below, text for the navigation bar title is provided - /// using a string. The navigation bar title's - /// ``NavigationBarItem/TitleDisplayMode`` is set to `.inline` which places - /// the navigation bar title in the bounds of the navigation bar. - /// - /// struct FlavorView: View { - /// let items = ["Chocolate", "Vanilla", "Strawberry", "Mint Chip", - /// "Pistachio"] - /// var body: some View { - /// NavigationView { - /// List(items, id: \.self) { - /// Text($0) - /// } - /// .navigationBarTitle("Today's Flavors", displayMode: .inline) - /// } - /// } - /// } - /// - /// If the `titleKey` can't be found, the title uses the text of the key - /// name instead. - /// - /// - Parameters: - /// - titleKey: A key to a localized description of this view to display - /// in the navigation bar. - /// - displayMode: The style to use for displaying the navigation bar - /// title. - @available(iOS, introduced: 13.0, deprecated: 100000.0, message: "Use navigationTitle(_:) with navigationBarTitleDisplayMode(_:)") - @available(macOS, unavailable) - @available(tvOS, unavailable) - @available(watchOS, unavailable) - @available(xrOS, introduced: 1.0, deprecated: 100000.0, message: "Use navigationTitle(_:) with navigationBarTitleDisplayMode(_:)") - public func navigationBarTitle(_ titleKey: LocalizedStringKey, displayMode: NavigationBarItem.TitleDisplayMode) -> some View { return stubView() } - - - /// Sets the title and display mode in the navigation bar for this view. - /// - /// Use `navigationBarTitle(_:displayMode:)` to set the title of the - /// navigation bar for this view and specify a display mode for the - /// title from one of the `NavigationBarItem.Title.DisplayMode` - /// styles. This modifier only takes effect when this view is inside of and - /// visible within a `NavigationView`. - /// - /// In the example below, `navigationBarTitle(_:displayMode:)` uses a - /// string to provide a title for the navigation bar. Setting the title's - /// `displayMode` to `.inline` places the navigation bar title within the - /// bounds of the navigation bar. - /// - /// In the example below, text for the navigation bar title is provided using - /// a string. The navigation bar title's `displayMode` is set to - /// `.inline` which places the navigation bar title in the bounds of the - /// navigation bar. - /// - /// struct FlavorView: View { - /// let items = ["Chocolate", "Vanilla", "Strawberry", "Mint Chip", - /// "Pistachio"] - /// let title = "Today's Flavors" - /// var body: some View { - /// NavigationView { - /// List(items, id: \.self) { - /// Text($0) - /// } - /// .navigationBarTitle(title, displayMode: .inline) - /// } - /// } - /// } - /// - /// ![A screenshot of a navigation bar, showing the title within the bounds - /// of the navigation bar] - /// (SkipUI-navigationBarTitle-stringProtocol.png) - /// - /// - Parameters: - /// - title: A title for this view to display in the navigation bar. - /// - displayMode: The way to display the title. - @available(iOS, introduced: 14.0, deprecated: 100000.0, message: "Use navigationTitle(_:) with navigationBarTitleDisplayMode(_:)") - @available(macOS, unavailable) - @available(tvOS, unavailable) - @available(watchOS, unavailable) - @available(xrOS, introduced: 1.0, deprecated: 100000.0, message: "Use navigationTitle(_:) with navigationBarTitleDisplayMode(_:)") - public func navigationBarTitle(_ title: S, displayMode: NavigationBarItem.TitleDisplayMode) -> some View where S : StringProtocol { return stubView() } - - /// Hides the navigation bar back button for the view. /// /// Use `navigationBarBackButtonHidden(_:)` to hide the back button for this @@ -1787,85 +1575,6 @@ extension View { } -@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) -extension View { - - /// Configures the view's title for purposes of navigation. - /// - /// A view's navigation title is used to visually display - /// the current navigation state of an interface. - /// On iOS and watchOS, when a view is navigated to inside - /// of a navigation view, that view's title is displayed - /// in the navigation bar. On iPadOS, the primary destination's - /// navigation title is reflected as the window's title in the - /// App Switcher. Similarly on macOS, the primary destination's title - /// is used as the window title in the titlebar, Windows menu - /// and Mission Control. - /// - /// Refer to the article - /// for more information on navigation title modifiers. - /// - /// - Parameter title: The title to display. - public func navigationTitle(_ title: Text) -> some View { return stubView() } - - - /// Configures the view's title for purposes of navigation, - /// using a localized string. - /// - /// A view's navigation title is used to visually display - /// the current navigation state of an interface. - /// On iOS and watchOS, when a view is navigated to inside - /// of a navigation view, that view's title is displayed - /// in the navigation bar. On iPadOS, the primary destination's - /// navigation title is reflected as the window's title in the - /// App Switcher. Similarly on macOS, the primary destination's title - /// is used as the window title in the titlebar, Windows menu - /// and Mission Control. - /// - /// Refer to the article - /// for more information on navigation title modifiers. - /// - /// - Parameter titleKey: The key to a localized string to display. - public func navigationTitle(_ titleKey: LocalizedStringKey) -> some View { return stubView() } - - - /// Configures the view's title for purposes of navigation, using a string. - /// - /// A view's navigation title is used to visually display - /// the current navigation state of an interface. - /// On iOS and watchOS, when a view is navigated to inside - /// of a navigation view, that view's title is displayed - /// in the navigation bar. On iPadOS, the primary destination's - /// navigation title is reflected as the window's title in the - /// App Switcher. Similarly on macOS, the primary destination's title - /// is used as the window title in the titlebar, Windows menu - /// and Mission Control. - /// - /// Refer to the article - /// for more information on navigation title modifiers. - /// - /// - Parameter title: The string to display. - public func navigationTitle(_ title: S) -> some View where S : StringProtocol { return stubView() } - -} - -extension View { - - /// Configures the view's title for purposes of navigation, using a string - /// binding. - /// - /// In iOS, iPadOS, and macOS, this allows editing the navigation title - /// when the title is displayed in the toolbar. - /// - /// Refer to the article - /// for more information on navigation title modifiers. - /// - /// - Parameter title: The text of the title. - @available(iOS 16.0, macOS 13.0, watchOS 9.0, tvOS 16.0, *) - public func navigationTitle(_ title: Binding) -> some View { return stubView() } - -} - @available(iOS 14.0, watchOS 8.0, *) @available(macOS, unavailable) @available(tvOS, unavailable) diff --git a/Sources/SkipUI/SkipUI/Environment/Environment.swift b/Sources/SkipUI/SkipUI/Environment/Environment.swift index dda5767d..626c3aba 100644 --- a/Sources/SkipUI/SkipUI/Environment/Environment.swift +++ b/Sources/SkipUI/SkipUI/Environment/Environment.swift @@ -34,17 +34,18 @@ public class EnvironmentValues { #if SKIP // We type erase all keys and values. The alternative would be to reify these functions. - let compositionLocals: MutableMap> = mutableMapOf() let lastSetValues: MutableMap, Any> = mutableMapOf() // SKIP DECLARE: @Composable operator fun get(key: KClass): Value where Key: EnvironmentKey + /// Retrieve an environment value by its `EnvironmentKey`. public func get(key: AnyHashable) -> Any { let compositionLocal = valueCompositionLocal(key: key) return compositionLocal.current as! Value } // SKIP DECLARE: @Composable fun environmentObject(type: KClass): ObjectType? where ObjectType: Any + /// Retrieve an environment object by type. public func environmentObject(type: ObjectType.Type) -> ObjectType? { let compositionLocal = objectCompositionLocal(type: type) let value = compositionLocal.current @@ -71,18 +72,20 @@ public class EnvironmentValues { // On set we populate our `lastSetValues` map, which our `setValues` function reads from and then clears after // packaging the values for sending to downstream Composables. This should be safe to do even on this effectively // global object because it should only be occurring sequentially on the main thread. - // + // SKIP DECLARE: operator fun set(key: KClass, value: Value) where Key: EnvironmentKey, Value: Any public func set(key: AnyHashable, value: Any) { let compositionLocal = valueCompositionLocal(key: key) lastSetValues[compositionLocal] = value } + /// The Compose `CompositionLocal` for the given environment value key type. public func valueCompositionLocal(key: Any.Type) -> ProvidableCompositionLocal { // SKIP INSERT: val defaultValue = { (key.companionObjectInstance as EnvironmentKeyCompanion<*>).defaultValue } return compositionLocal(key: key, defaultValue: defaultValue) } + /// The Compose `CompositionLocal` for the given environment object type. public func objectCompositionLocal(type: Any.Type) -> ProvidableCompositionLocal { return compositionLocal(key: type, defaultValue: { nil }) } @@ -95,41 +98,6 @@ public class EnvironmentValues { compositionLocals[key] = value return value } - - // SKIP DECLARE: @Composable internal fun preference(keyType: KClass): Preference? where Key: PreferenceKey, Value: Any - func preference(keyType: AnyHashable) -> Preference? { - return compositionLocals[keyType]?.current as? Preference - } - - // SKIP DECLARE: @Composable internal fun setPreference(preference: Preference) where Key: PreferenceKey, Value: Any - func collectPreferences(_ preference: Preference) { - // do below for each key... - // begin collecting each - // execute composable - // end collecting each? - if let value = compositionLocals[preference.keyType] { - value.current = preference - } else { - let value = compositionLocalOf { preference } - compositionLocals[key] = value - } - } - /// Set environment values. - /// - /// - Seealso: ``View/environment(_:)`` - /// - Warning: Setting environment values should only be done within the `execute` block of this function. - @Composable func setValues(_ execute: @Composable (EnvironmentValues) -> Void, in content: @Composable () -> Void) { - // Set the values in EnvironmentValues to keep any user-defined setter logic in place, then retrieve and clear the last set values - execute(self) - let provided = lastSetValues.map { entry in - // SKIP INSERT: val element = entry.key provides entry.value - element - }.toTypedArray() - lastSetValues.clear() - CompositionLocalProvider(*provided) { - content() - } - } #endif } @@ -179,12 +147,11 @@ extension View { } } -// The transpiler translates the `EnvironmentValues` extension vars we add (or when apps add their own) from a get/set -// var into a get-only @Composable var and a setter function. @Composable vars cannot have setters +#if SKIP -// Internal values +// The transpiler translates the `EnvironmentValues` extension vars we add (or when apps add their own) from a +// get/set var into a get-only @Composable var and a setter function. @Composable vars cannot have setters -#if SKIP extension EnvironmentValues { @Composable private func builtinValue(key: AnyHashable, defaultValue: () -> Any?) -> Any? { let compositionLocal = compositionLocal(key: key, defaultValue: defaultValue) @@ -197,6 +164,15 @@ extension EnvironmentValues { lastSetValues[compositionLocal] = value ?? Unit } + // MARK: - SwiftUI values + + public var font: Font? { + get { builtinValue(key: "font", defaultValue: { nil }) as! Font? } + set { setBuiltinValue(key: "font", value: newValue, defaultValue: { nil }) } + } + + // MARK: - Internal values + var _buttonStyle: ButtonStyle? { get { builtinValue(key: "_buttonStyle", defaultValue: { nil }) as! ButtonStyle? } set { setBuiltinValue(key: "_buttonStyle", value: newValue, defaultValue: { nil }) } @@ -239,17 +215,6 @@ extension EnvironmentValues { } #endif -// SwiftUI values - -#if SKIP -extension EnvironmentValues { - public var font: Font? { - get { builtinValue(key: "font", defaultValue: { nil }) as! Font? } - set { setBuiltinValue(key: "font", value: newValue, defaultValue: { nil }) } - } -} -#endif - #if !SKIP // TODO: Process for use in SkipUI diff --git a/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift b/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift index f2f3b323..34f25b57 100644 --- a/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift +++ b/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift @@ -3,6 +3,9 @@ // as published by the Free Software Foundation https://fsf.org #if SKIP +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf import kotlin.reflect.full.companionObjectInstance #endif @@ -18,28 +21,77 @@ public protocol PreferenceKeyCompanion { func reduce(value: inout Value, nextValue: () -> Value) } -// SKIP DECLARE: class Preference where Key: PreferenceKey, Value: Any +/// Internal analog to `EnvironmentValues` for preferences. +/// +/// Uses environment `CompositionLocals` internally. +/// +/// - Seealso: `EnvironmentValues` +class PreferenceValues { + static let shared = PreferenceValues() + + // SKIP DECLARE: @Composable internal fun preference(key: KClass<*>): Preference<*>? + /// Return a preference for the given `PreferenceKey` type. + func preference(key: Any.Type) -> any Preference? { + return EnvironmentValues.shared.compositionLocals[key]?.current as? Preference + } + + // SKIP DECLARE: @Composable internal fun collectPreferences(preferences: Array>, in_: @Composable () -> Unit) + /// Collect the values of the given preferences while composing the given content. + func collectPreferences(_ preferences: [any Preference], in content: @Composable () -> Void) { + let provided = preferences.map { preference in + var compositionLocal = EnvironmentValues.shared.compositionLocals[preference.key] + if compositionLocal == nil { + compositionLocal = compositionLocalOf { Unit } + EnvironmentValues.shared.compositionLocals[preference.key] = compositionLocal + } + // SKIP INSERT: val element = compositionLocal!! provides preference + element + }.kotlin(nocopy: true).toTypedArray() + + preferences.forEach { $0.beginCollecting() } + CompositionLocalProvider(*provided) { + content() + } + preferences.forEach { $0.endCollecting() } + } +} + /// Used internally by our preferences system to collect preferences and recompose on change. -class Preference { - let keyType: Key.Type - private let update: () -> Void +class Preference { + let key: Any.Type + private let update: (Value) -> Void + private let didChange: () -> Void private let defaultValue: Value + private var isCollecting = false private var collectedValue: Value? - init(keyType: Key.Type, update: () -> Void) { - self.keyType = keyType + /// Create a preference for the given `PreferenceKey` type. + /// + /// - Parameter update: Block to call to change the value of this preference. + /// - Parameter didChange: Block to call if this preference changes. Should force a recompose of the relevant content, collecting the new value via `collectPreferences` + init(key: Any.Type, update: (Value) -> Void, didChange: () -> Void) { + self.key = key self.update = update - self.defaultValue = (keyType.companionObjectInstance as! PreferenceKeyCompanion).defaultValue + self.didChange = didChange + self.defaultValue = (key.companionObjectInstance as! PreferenceKeyCompanion).defaultValue } - /// Whether we're currently collecting the preference value for use. - private(set) var isCollecting = false - /// The current preference value. var value: Value { return collectedValue ?? defaultValue } + /// Reduce the current value and the given values. + func reduce(savedValue: Any?, newValue: Any) { + if isCollecting { + var value = self.value + (key.companionObjectInstance as! PreferenceKeyCompanion).reduce(value: &value, nextValue: { newValue as! Value }) + collectedValue = value + } else if savedValue != newValue { + didChange() + } + } + /// Begin collecting the current value. /// /// Call this before composing content. @@ -51,38 +103,19 @@ class Preference { /// End collecting the current value. /// /// Call this after composing content. - func endCollecting() -> Value { + func endCollecting() { isCollecting = false - return value - } - - /// Reduce the current value and the given value. - func reduce(_ value: Value) { - var newValue = value - (keyType.companionObjectInstance as! PreferenceKeyCompanion).reduce(value: &newValue, nextValue: { value }) - collectedValue = newValue - } - - /// Called by content when their preference value changes. - func setNeedsUpdate() { - update() + update(value) } } #endif extension View { - // SKIP DECLARE: fun preference(key: KClass, value: Value): View where Key: PreferenceKey, Value: Any - public func preference(key: AnyHashable, value: Any) -> some View { + public func preference(key: Any.Type, value: Any) -> some View { #if SKIP return ComposeModifierView(contentView: self) { view, context in - // SKIP INSERT: val pvalue by rememberSaveable(saver = context.stateSaver as Saver) { mutableStateOf(value) } - if let preference = EnvironmentValues.shared.preference(keyType: key) { - if preference.isCollecting { - preference.reduce(value) - } else if pvalue != value { - preference.setNeedsUpdate() - } - } + // SKIP INSERT: var pvalue by remember { mutableStateOf(null) } + PreferenceValues.shared.preference(key: key)?.reduce(savedValue: pvalue, newValue: value) pvalue = value view.Compose(context: context) }