1 /* 2 * Copyright (C) 2024 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 * http://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 package com.android.photopicker.features.categorygrid 18 19 import androidx.compose.animation.AnimatedContentTransitionScope 20 import androidx.compose.animation.EnterTransition 21 import androidx.compose.animation.ExitTransition 22 import androidx.compose.animation.slideInHorizontally 23 import androidx.compose.animation.slideOutHorizontally 24 import androidx.compose.runtime.Composable 25 import androidx.compose.ui.Modifier 26 import androidx.navigation.NamedNavArgument 27 import androidx.navigation.NavBackStackEntry 28 import androidx.navigation.NavDeepLink 29 import com.android.photopicker.core.animations.springDefaultEffectOffset 30 import com.android.photopicker.core.configuration.PhotopickerConfiguration 31 import com.android.photopicker.core.events.Event 32 import com.android.photopicker.core.events.RegisteredEventClass 33 import com.android.photopicker.core.features.FeatureManager 34 import com.android.photopicker.core.features.FeatureRegistration 35 import com.android.photopicker.core.features.FeatureToken 36 import com.android.photopicker.core.features.Location 37 import com.android.photopicker.core.features.LocationParams 38 import com.android.photopicker.core.features.PhotopickerUiFeature 39 import com.android.photopicker.core.features.PrefetchResultKey 40 import com.android.photopicker.core.features.Priority 41 import com.android.photopicker.core.navigation.PhotopickerDestinations 42 import com.android.photopicker.core.navigation.PhotopickerDestinations.ALBUM_GRID 43 import com.android.photopicker.core.navigation.PhotopickerDestinations.ALBUM_MEDIA_GRID 44 import com.android.photopicker.core.navigation.PhotopickerDestinations.PHOTO_GRID 45 import com.android.photopicker.core.navigation.Route 46 import com.android.photopicker.data.model.Group 47 import kotlinx.coroutines.Deferred 48 import kotlinx.coroutines.flow.StateFlow 49 50 /** 51 * Feature class for the Photopicker's main category grid. 52 * 53 * This feature adds the [CategoryGrid] route and [MediaSet] route. 54 */ 55 class CategoryGridFeature : PhotopickerUiFeature { 56 companion object Registration : FeatureRegistration { 57 override val TAG: String = "PhotoPickerCategoryGridFeature" 58 isEnablednull59 override fun isEnabled( 60 config: PhotopickerConfiguration, 61 deferredPrefetchResultsMap: Map<PrefetchResultKey, Deferred<Any?>>, 62 ) = config.flags.PICKER_SEARCH_ENABLED 63 64 override fun build(featureManager: FeatureManager) = CategoryGridFeature() 65 66 const val GROUP_KEY = "selected_group" 67 } 68 69 override val token = FeatureToken.CATEGORY_GRID.token 70 71 /** Events consumed by the Category grid */ 72 override val eventsConsumed = emptySet<RegisteredEventClass>() 73 74 /** Events produced by the Category grid */ 75 override val eventsProduced = 76 setOf( 77 Event.ShowSnackbarMessage::class.java, 78 Event.LogPhotopickerUIEvent::class.java, 79 Event.LogPhotopickerAlbumOpenedUIEvent::class.java, 80 Event.LogPhotopickerPageInfo::class.java, 81 ) 82 83 override fun registerLocations(): List<Pair<Location, Int>> { 84 return listOf(Pair(Location.NAVIGATION_BAR_NAV_BUTTON, Priority.HIGHEST.priority)) 85 } 86 registerNavigationRoutesnull87 override fun registerNavigationRoutes(): Set<Route> { 88 return setOf( 89 // The main grid of the user's category. 90 object : Route { 91 override val route = ALBUM_GRID.route 92 override val initialRoutePriority = Priority.HIGH.priority 93 override val arguments = emptyList<NamedNavArgument>() 94 override val deepLinks = emptyList<NavDeepLink>() 95 override val isDialog = false 96 override val dialogProperties = null 97 98 /* 99 Animations for ALBUM_GRID for CategoryGridFeature 100 - When navigating directly, content will slide IN from the left edge. 101 - When navigating away, content will slide OUT towards the left edge. 102 - When returning from the backstack, content will slide IN from the right edge. 103 - When popping to another route on the backstack, content will slide OUT towards 104 the left edge. 105 */ 106 override val enterTransition: 107 (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition)? = 108 { 109 if (initialState.destination.route == PHOTO_GRID.route) { 110 // Positive value to slide left-to-right 111 slideInHorizontally(animationSpec = springDefaultEffectOffset) { it } 112 } else { 113 // Negative value to slide right-to-left 114 // if previous route was not from PHOTO_GRID. 115 slideInHorizontally(animationSpec = springDefaultEffectOffset) { -it } 116 } 117 } 118 override val exitTransition: 119 (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition)? = 120 { 121 if (targetState.destination.route == PHOTO_GRID.route) { 122 // Positive value to slide left-to-right 123 slideOutHorizontally(animationSpec = springDefaultEffectOffset) { it } 124 } else { 125 // Negative value to slide right-to-left 126 // if target route is not PHOTO_GRID 127 slideOutHorizontally(animationSpec = springDefaultEffectOffset) { -it } 128 } 129 } 130 override val popEnterTransition: 131 (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition)? = 132 { 133 if (initialState.destination.route == PHOTO_GRID.route) { 134 // Positive value to slide left-to-right 135 slideInHorizontally(animationSpec = springDefaultEffectOffset) { it } 136 } else { 137 // Negative value to slide right-to-left 138 // if previous route was not from PHOTO_GRID. 139 slideInHorizontally(animationSpec = springDefaultEffectOffset) { -it } 140 } 141 } 142 override val popExitTransition: 143 (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition)? = 144 { 145 if (targetState.destination.route == PHOTO_GRID.route) { 146 // Positive value to slide left-to-right 147 slideOutHorizontally(animationSpec = springDefaultEffectOffset) { it } 148 } else { 149 // Negative value to slide right-to-left 150 // if target route is not PHOTO_GRID 151 slideOutHorizontally(animationSpec = springDefaultEffectOffset) { -it } 152 } 153 } 154 155 @Composable 156 override fun composable(navBackStackEntry: NavBackStackEntry?) { 157 CategoryGrid() 158 } 159 }, 160 // Grid to show the media sets for the category selected by the user. 161 object : Route { 162 override val route = PhotopickerDestinations.MEDIA_SET_GRID.route 163 override val initialRoutePriority = Priority.MEDIUM.priority 164 override val arguments = emptyList<NamedNavArgument>() 165 override val deepLinks = emptyList<NavDeepLink>() 166 override val isDialog = false 167 override val dialogProperties = null 168 169 /** 170 * Animations for MEDIA_SET_GRID are by default [EnterTransition.None] for entering 171 * into view and [ExitTransition.None] while exiting. 172 */ 173 override val enterTransition: 174 (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition)? = 175 { 176 if ( 177 initialState.destination.route != 178 PhotopickerDestinations.MEDIA_SET_CONTENT_GRID.route 179 ) { 180 // Positive value to slide left-to-right 181 slideInHorizontally(animationSpec = springDefaultEffectOffset) { it } 182 } else { 183 slideInHorizontally(animationSpec = springDefaultEffectOffset) { -it } 184 } 185 } 186 override val exitTransition: 187 (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition)? = 188 { 189 if ( 190 targetState.destination.route != 191 PhotopickerDestinations.MEDIA_SET_CONTENT_GRID.route 192 ) { 193 slideOutHorizontally(animationSpec = springDefaultEffectOffset) { it } 194 } else { 195 slideOutHorizontally(animationSpec = springDefaultEffectOffset) { -it } 196 } 197 } 198 override val popEnterTransition: 199 (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition)? = 200 { 201 if ( 202 initialState.destination.route != 203 PhotopickerDestinations.MEDIA_SET_CONTENT_GRID.route 204 ) { 205 slideInHorizontally(animationSpec = springDefaultEffectOffset) { it } 206 } else { 207 slideInHorizontally(animationSpec = springDefaultEffectOffset) { -it } 208 } 209 } 210 override val popExitTransition: 211 (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition)? = 212 { 213 if ( 214 targetState.destination.route != 215 PhotopickerDestinations.MEDIA_SET_CONTENT_GRID.route 216 ) { 217 slideOutHorizontally(animationSpec = springDefaultEffectOffset) { it } 218 } else { 219 slideOutHorizontally(animationSpec = springDefaultEffectOffset) { -it } 220 } 221 } 222 223 @Composable 224 override fun composable(navBackStackEntry: NavBackStackEntry?) { 225 val flow: StateFlow<Group.Category?> = 226 checkNotNull( 227 navBackStackEntry 228 ?.savedStateHandle 229 ?.getStateFlow<Group.Category?>(GROUP_KEY, null) 230 ) { 231 "Unable to get a savedStateHandle for media set grid" 232 } 233 MediaSetGrid(flow) 234 } 235 }, 236 // Grid to show the album content for the album selected by the user. 237 object : Route { 238 override val route = ALBUM_MEDIA_GRID.route 239 override val initialRoutePriority = Priority.MEDIUM.priority 240 override val arguments = emptyList<NamedNavArgument>() 241 override val deepLinks = emptyList<NavDeepLink>() 242 override val isDialog = false 243 override val dialogProperties = null 244 245 /** 246 * Animations for CATEGORY_CONTENT_GRID are by default [EnterTransition.None] for 247 * entering into view and [ExitTransition.None] while exiting. 248 */ 249 override val enterTransition: 250 (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition)? = 251 { 252 // Positive value to slide left-to-right 253 slideInHorizontally(animationSpec = springDefaultEffectOffset) { it } 254 } 255 override val exitTransition: 256 (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition)? = 257 { 258 slideOutHorizontally(animationSpec = springDefaultEffectOffset) { it } 259 } 260 override val popEnterTransition: 261 (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition)? = 262 { 263 slideInHorizontally(animationSpec = springDefaultEffectOffset) { it } 264 } 265 override val popExitTransition: 266 (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition)? = 267 { 268 slideOutHorizontally(animationSpec = springDefaultEffectOffset) { it } 269 } 270 271 @Composable 272 override fun composable(navBackStackEntry: NavBackStackEntry?) { 273 val flow: StateFlow<Group.Album?> = 274 checkNotNull( 275 navBackStackEntry 276 ?.savedStateHandle 277 ?.getStateFlow<Group.Album?>(GROUP_KEY, null) 278 ) { 279 "Unable to get a savedStateHandle for album content grid" 280 } 281 AlbumMediaGrid(flow) 282 } 283 }, 284 // Grid to show the media set content for the media set selected by the user. 285 object : Route { 286 override val route = PhotopickerDestinations.MEDIA_SET_CONTENT_GRID.route 287 override val initialRoutePriority = Priority.MEDIUM.priority 288 override val arguments = emptyList<NamedNavArgument>() 289 override val deepLinks = emptyList<NavDeepLink>() 290 override val isDialog = false 291 override val dialogProperties = null 292 293 /** 294 * Animations for CATEGORY_CONTENT_GRID are by default [EnterTransition.None] for 295 * entering into view and [ExitTransition.None] while exiting. 296 */ 297 override val enterTransition: 298 (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition)? = 299 { 300 // Positive value to slide left-to-right 301 slideInHorizontally(animationSpec = springDefaultEffectOffset) { it } 302 } 303 override val exitTransition: 304 (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition)? = 305 { 306 slideOutHorizontally(animationSpec = springDefaultEffectOffset) { it } 307 } 308 override val popEnterTransition: 309 (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition)? = 310 { 311 slideInHorizontally(animationSpec = springDefaultEffectOffset) { it } 312 } 313 override val popExitTransition: 314 (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition)? = 315 { 316 slideOutHorizontally(animationSpec = springDefaultEffectOffset) { it } 317 } 318 319 @Composable 320 override fun composable(navBackStackEntry: NavBackStackEntry?) { 321 val flow: StateFlow<Group.MediaSet?> = 322 checkNotNull( 323 navBackStackEntry 324 ?.savedStateHandle 325 ?.getStateFlow<Group.MediaSet?>(GROUP_KEY, null) 326 ) { 327 "Unable to get a savedStateHandle for album content grid" 328 } 329 MediaSetContentGrid(flow) 330 } 331 }, 332 ) 333 } 334 335 @Composable composenull336 override fun compose(location: Location, modifier: Modifier, params: LocationParams) { 337 when (location) { 338 Location.NAVIGATION_BAR_NAV_BUTTON -> CategoryButton(modifier) 339 else -> {} 340 } 341 } 342 } 343