1 /* 2 * Copyright 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * https://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 @file:OptIn(ExperimentalHorologistApi::class, SavedStateHandleSaveableApi::class) 18 19 package com.google.android.horologist.compose.navscaffold 20 21 import android.os.Bundle 22 import androidx.compose.foundation.ScrollState 23 import androidx.compose.foundation.gestures.ScrollableState 24 import androidx.compose.foundation.lazy.LazyListState 25 import androidx.compose.runtime.getValue 26 import androidx.compose.runtime.mutableStateOf 27 import androidx.compose.runtime.setValue 28 import androidx.lifecycle.SavedStateHandle 29 import androidx.lifecycle.ViewModel 30 import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi 31 import androidx.lifecycle.viewmodel.compose.saveable 32 import androidx.navigation.NavBackStackEntry 33 import androidx.navigation.NavHostController 34 import androidx.wear.compose.foundation.lazy.ScalingLazyListState 35 import androidx.wear.compose.material.PositionIndicator 36 import androidx.wear.compose.material.Scaffold 37 import androidx.wear.compose.material.TimeText 38 import androidx.wear.compose.material.Vignette 39 import androidx.wear.compose.material.VignettePosition 40 import com.google.android.horologist.annotations.ExperimentalHorologistApi 41 import com.google.android.horologist.compose.layout.ScalingLazyColumnState 42 import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.PositionIndicatorMode 43 import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.TimeTextMode 44 import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.TimeTextMode.ScrollAway 45 import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.VignetteMode.Off 46 import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.VignetteMode.On 47 import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.VignetteMode.WhenScrollable 48 49 /** 50 * A ViewModel that backs the WearNavScaffold to allow each composable to interact and effect 51 * the [Scaffold] positionIndicator, vignette and timeText. 52 * 53 * A ViewModel is used to allow the same current instance to be shared between the WearNavScaffold 54 * and the composable screen via [NavHostController.currentBackStackEntry]. 55 */ 56 public open class NavScaffoldViewModel( 57 private val savedStateHandle: SavedStateHandle, 58 ) : ViewModel() { 59 internal var initialIndex: Int? = null 60 internal var initialOffsetPx: Int? = null 61 internal var scrollType by mutableStateOf<ScrollType?>(null) 62 63 private lateinit var _scrollableState: ScrollableState 64 65 /** 66 * Returns the scrollable state for this composable or null if the scaffold should 67 * not consider this element to be scrollable. 68 */ 69 public val scrollableState: ScrollableState? 70 get() = if (scrollType == null || scrollType == ScrollType.None) { 71 null 72 } else { 73 _scrollableState 74 } 75 76 /** 77 * The configuration of [Vignette], [WhenScrollable], [Off], [On] and if so whether top and 78 * bottom. Defaults to on for scrollable screens. 79 */ 80 public var vignettePosition: VignetteMode by mutableStateOf(WhenScrollable) 81 82 /** 83 * The configuration of [TimeText], defaults to [TimeTextMode.ScrollAway] which will move the 84 * time text above the screen to avoid overlapping with the content moving up. 85 */ 86 public var timeTextMode: TimeTextMode by mutableStateOf(ScrollAway) 87 88 /** 89 * The configuration of [PositionIndicator]. The default is to show a scroll bar while the 90 * scroll is in progress. 91 */ 92 public var positionIndicatorMode: PositionIndicatorMode 93 by mutableStateOf(PositionIndicatorMode.On) 94 initializeScrollStatenull95 internal fun initializeScrollState(scrollStateBuilder: () -> ScrollState): ScrollState { 96 check(scrollType == null || scrollType == ScrollType.ScrollState) 97 98 if (scrollType == null) { 99 scrollType = ScrollType.ScrollState 100 101 _scrollableState = savedStateHandle.saveable( 102 key = "navScaffold.ScrollState", 103 saver = ScrollState.Saver, 104 ) { 105 scrollStateBuilder() 106 } 107 } 108 109 return _scrollableState as ScrollState 110 } 111 initializeScalingLazyListStatenull112 internal fun initializeScalingLazyListState( 113 scrollableStateBuilder: () -> ScalingLazyListState, 114 ): ScalingLazyListState { 115 check(scrollType == null || scrollType == ScrollType.ScalingLazyColumn) 116 117 if (scrollType == null) { 118 scrollType = ScrollType.ScalingLazyColumn 119 120 _scrollableState = savedStateHandle.saveable( 121 key = "navScaffold.ScalingLazyListState", 122 saver = ScalingLazyListState.Saver, 123 ) { 124 scrollableStateBuilder().also { 125 initialIndex = it.centerItemIndex 126 initialOffsetPx = it.centerItemScrollOffset 127 } 128 } 129 } 130 131 return _scrollableState as ScalingLazyListState 132 } 133 initializeScalingLazyListStatenull134 internal fun initializeScalingLazyListState( 135 columnState: ScalingLazyColumnState, 136 ) { 137 check(scrollType == null || scrollType == ScrollType.ScalingLazyColumn) 138 139 if (scrollType == null) { 140 scrollType = ScrollType.ScalingLazyColumn 141 142 initialIndex = columnState.initialScrollPosition.index 143 initialOffsetPx = columnState.initialScrollPosition.offsetPx 144 145 _scrollableState = savedStateHandle.saveable( 146 key = "navScaffold.ScalingLazyListState", 147 saver = ScalingLazyListState.Saver, 148 ) { 149 columnState.state 150 } 151 } 152 153 columnState.state = _scrollableState as ScalingLazyListState 154 } 155 initializeLazyListnull156 internal fun initializeLazyList( 157 scrollableStateBuilder: () -> LazyListState, 158 ): LazyListState { 159 check(scrollType == null || scrollType == ScrollType.LazyList) 160 161 if (scrollType == null) { 162 scrollType = ScrollType.LazyList 163 164 _scrollableState = savedStateHandle.saveable( 165 key = "navScaffold.LazyListState", 166 saver = LazyListState.Saver, 167 ) { 168 scrollableStateBuilder() 169 } 170 } 171 172 return _scrollableState as LazyListState 173 } 174 175 internal enum class ScrollType { 176 None, ScalingLazyColumn, ScrollState, LazyList 177 } 178 179 /** 180 * The configuration of [TimeText], defaults to [ScrollAway] which will move the time text above the 181 * screen to avoid overlapping with the content moving up. 182 */ 183 public enum class TimeTextMode { 184 On, Off, ScrollAway 185 } 186 187 /** 188 * The configuration of [PositionIndicator]. The default is to show a scroll bar while the 189 * scroll is in progress. 190 */ 191 public enum class PositionIndicatorMode { 192 On, Off 193 } 194 195 /** 196 * The configuration of [Vignette], [WhenScrollable], [Off], [On] and if so whether top and 197 * bottom. Defaults to on for scrollable screens. 198 */ 199 public sealed interface VignetteMode { 200 public object WhenScrollable : VignetteMode 201 public object Off : VignetteMode 202 public data class On(val position: VignettePosition) : VignetteMode 203 } 204 timeTextScrollableStatenull205 internal fun timeTextScrollableState(): ScrollableState? { 206 return when (timeTextMode) { 207 ScrollAway -> { 208 when (this.scrollType) { 209 ScrollType.ScrollState -> { 210 this.scrollableState as ScrollState 211 } 212 213 ScrollType.ScalingLazyColumn -> { 214 val scalingLazyListState = 215 this.scrollableState as ScalingLazyListState 216 217 ScalingLazyColumnScrollableState(scalingLazyListState, initialIndex 218 ?: 1, initialOffsetPx ?: 0) 219 } 220 221 ScrollType.LazyList -> { 222 this.scrollableState as LazyListState 223 } 224 225 else -> { 226 ScrollState(0) 227 } 228 } 229 } 230 231 TimeTextMode.On -> { 232 ScrollState(0) 233 } 234 235 else -> { 236 null 237 } 238 } 239 } 240 } 241 242 internal class ScalingLazyColumnScrollableState( 243 val scalingLazyListState: ScalingLazyListState, 244 val initialIndex: Int, 245 val initialOffsetPx: Int, 246 ) : ScrollableState by scalingLazyListState 247 248 /** 249 * The context items provided to a navigation composable. 250 * 251 * The [viewModel] can be used to customise the scaffold behaviour. 252 */ 253 public data class ScaffoldContext<T : ScrollableState>( 254 val backStackEntry: NavBackStackEntry, 255 val scrollableState: T, 256 val viewModel: NavScaffoldViewModel, 257 ) { 258 var timeTextMode: TimeTextMode by viewModel::timeTextMode 259 260 var positionIndicatorMode: PositionIndicatorMode by viewModel::positionIndicatorMode 261 262 val arguments: Bundle? 263 get() = backStackEntry.arguments 264 } 265 266 public data class NonScrollableScaffoldContext( 267 val backStackEntry: NavBackStackEntry, 268 val viewModel: NavScaffoldViewModel, 269 ) { 270 var timeTextMode: TimeTextMode by viewModel::timeTextMode 271 272 var positionIndicatorMode: PositionIndicatorMode by viewModel::positionIndicatorMode 273 274 val arguments: Bundle? 275 get() = backStackEntry.arguments 276 } 277 278 /** 279 * The context items provided to a navigation composable. 280 * 281 * The [viewModel] can be used to customise the scaffold behaviour. 282 */ 283 public data class ScrollableScaffoldContext( 284 val backStackEntry: NavBackStackEntry, 285 val columnState: ScalingLazyColumnState, 286 val viewModel: NavScaffoldViewModel, 287 ) { 288 val scrollableState: ScalingLazyListState 289 get() = columnState.state 290 291 var timeTextMode: TimeTextMode by viewModel::timeTextMode 292 293 var positionIndicatorMode: PositionIndicatorMode by viewModel::positionIndicatorMode 294 295 val arguments: Bundle? 296 get() = backStackEntry.arguments 297 } 298