• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright 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.navigationbar
18 
19 import androidx.compose.animation.expandVertically
20 import androidx.compose.animation.shrinkVertically
21 import androidx.compose.foundation.layout.Arrangement
22 import androidx.compose.foundation.layout.Column
23 import androidx.compose.foundation.layout.Row
24 import androidx.compose.foundation.layout.Spacer
25 import androidx.compose.foundation.layout.fillMaxWidth
26 import androidx.compose.foundation.layout.heightIn
27 import androidx.compose.foundation.layout.padding
28 import androidx.compose.foundation.layout.width
29 import androidx.compose.foundation.layout.widthIn
30 import androidx.compose.material.icons.Icons
31 import androidx.compose.material.icons.automirrored.filled.ArrowBack
32 import androidx.compose.material3.ButtonDefaults
33 import androidx.compose.material3.FilledTonalButton
34 import androidx.compose.material3.Icon
35 import androidx.compose.material3.IconButton
36 import androidx.compose.material3.MaterialTheme
37 import androidx.compose.material3.Text
38 import androidx.compose.runtime.Composable
39 import androidx.compose.runtime.getValue
40 import androidx.compose.runtime.remember
41 import androidx.compose.ui.Alignment
42 import androidx.compose.ui.Modifier
43 import androidx.compose.ui.res.stringResource
44 import androidx.compose.ui.semantics.semantics
45 import androidx.compose.ui.semantics.traversalIndex
46 import androidx.compose.ui.text.style.TextOverflow
47 import androidx.compose.ui.unit.dp
48 import androidx.navigation.compose.currentBackStackEntryAsState
49 import com.android.photopicker.R
50 import com.android.photopicker.core.StateSelector
51 import com.android.photopicker.core.animations.standardDecelerate
52 import com.android.photopicker.core.embedded.LocalEmbeddedState
53 import com.android.photopicker.core.features.LocalFeatureManager
54 import com.android.photopicker.core.features.Location
55 import com.android.photopicker.core.features.LocationParams
56 import com.android.photopicker.core.hideWhenState
57 import com.android.photopicker.core.navigation.LocalNavController
58 import com.android.photopicker.core.navigation.PhotopickerDestinations
59 import com.android.photopicker.core.theme.CustomAccentColorScheme
60 import com.android.photopicker.data.model.Group
61 import com.android.photopicker.extensions.navigateToAlbumGrid
62 import com.android.photopicker.extensions.navigateToCategoryGrid
63 import com.android.photopicker.extensions.navigateToMediaSetGrid
64 import com.android.photopicker.features.albumgrid.AlbumGridFeature
65 import com.android.photopicker.features.categorygrid.CategoryGridFeature
66 import com.android.photopicker.features.overflowmenu.OverflowMenuFeature
67 import com.android.photopicker.features.profileselector.ProfileSelectorFeature
68 import com.android.photopicker.features.search.SearchFeature
69 
70 /* Navigation bar button measurements */
71 private val MEASUREMENT_ICON_BUTTON_WIDTH = 48.dp
72 private val MEASUREMENT_ICON_BUTTON_OUTSIDE_PADDING = 4.dp
73 
74 /* Distance between two navigation buttons */
75 private val MEASUREMENT_SPACER_SIZE = 8.dp
76 
77 /* Padding values around the edges of the NavigationBar */
78 private val MEASUREMENT_EDGE_PADDING = 4.dp
79 private val MEASUREMENT_TOP_PADDING = 8.dp
80 private val MEASUREMENT_BOT_PADDING = 24.dp
81 
82 /* Minimum height for the NavigationBar */
83 private val MEASUREMENT_MIN_HEIGHT = 48.dp
84 
85 /**
86  * Top of the NavigationBar feature.
87  *
88  * Since NavigationBar doesn't register any routes its top composable is drawn at
89  * [Location.NAVIGATION_BAR] which begins here.
90  *
91  * This composable provides a full width row for the navigation bar and calls the feature manager to
92  * provide [NavigationBarButton]s for the row.
93  *
94  * If the search feature is enabled [Location.SEARCH_BAR] is drawn above the [NavigationBarButtons]
95  * at the top.
96  *
97  * Additionally, the composable also calls for the [PROFILE_SELECTOR] and [OVERFLOW_MENU] locations.
98  */
99 @Composable
NavigationBarnull100 fun NavigationBar(modifier: Modifier = Modifier, params: LocationParams) {
101 
102     val navController = LocalNavController.current
103     val navBackStackEntry by navController.currentBackStackEntryAsState()
104     val currentRoute = navBackStackEntry?.destination?.route
105     val featureManager = LocalFeatureManager.current
106     val searchFeatureEnabled = featureManager.isFeatureEnabled(SearchFeature::class.java)
107 
108     Row(
109         modifier =
110             modifier
111                 .padding(
112                     start = MEASUREMENT_EDGE_PADDING,
113                     end = MEASUREMENT_EDGE_PADDING,
114                     top = MEASUREMENT_TOP_PADDING,
115                     bottom = MEASUREMENT_BOT_PADDING,
116                 )
117                 .heightIn(min = MEASUREMENT_MIN_HEIGHT),
118         horizontalArrangement = Arrangement.SpaceBetween,
119         verticalAlignment = Alignment.Top,
120     ) {
121         when {
122 
123             // When inside an album display the album title and a back button,
124             // instead of the normal navigation bar contents.
125             currentRoute == PhotopickerDestinations.ALBUM_MEDIA_GRID.route -> {
126                 if (featureManager.isFeatureEnabled(AlbumGridFeature::class.java)) {
127                     NavigationBarForAlbum(modifier)
128                 } else {
129                     NavigationBarForGroup(modifier)
130                 }
131             }
132 
133             currentRoute == PhotopickerDestinations.MEDIA_SET_GRID.route -> {
134                 NavigationBarForGroup(modifier)
135             }
136 
137             currentRoute == PhotopickerDestinations.MEDIA_SET_CONTENT_GRID.route -> {
138                 NavigationBarForGroup(modifier)
139             }
140 
141             // When search feature is enabled then display search bar along with profile selector,
142             // overflow menu and the navigation buttons below it.
143             searchFeatureEnabled -> NavigationBarWithSearch(modifier, params)
144 
145             // For all other routes, show the profile selector and the navigation buttons
146             else -> BasicNavigationBar(modifier)
147         }
148     }
149 }
150 
151 /**
152  * Composable that can be used to build a consistent button for the [NavigationBarFeature]
153  *
154  * @param onClick the handler to run when the button is clicked.
155  * @param modifier A modifier which is applied directly to the button. This should be the modifier
156  *   that is passed via the Location compose call.
157  * @param isCurrentRoute a function which receives the current
158  *   [NavController.currentDestination.route] and returns true if that route matches the route this
159  *   button represents.
160  * @param buttonContent A composable to render as the button's content. Should most likely be a
161  *   string label.
162  */
163 @Composable
NavigationBarButtonnull164 fun NavigationBarButton(
165     onClick: () -> Unit,
166     modifier: Modifier,
167     isCurrentRoute: (String) -> Boolean,
168     buttonContent: @Composable () -> Unit,
169 ) {
170     val navController = LocalNavController.current
171     val navBackStackEntry by navController.currentBackStackEntryAsState()
172     val currentRoute = navBackStackEntry?.destination?.route
173     val featureManager = LocalFeatureManager.current
174     val categoryGridFeatureEnabled =
175         featureManager.isFeatureEnabled(CategoryGridFeature::class.java)
176 
177     FilledTonalButton(
178         onClick = onClick,
179         modifier =
180             if (categoryGridFeatureEnabled) {
181                 modifier.widthIn(min = 120.dp, max = 120.dp)
182             } else {
183                 modifier
184             },
185         shape = MaterialTheme.shapes.medium,
186         contentPadding = ButtonDefaults.TextButtonContentPadding,
187         colors =
188             if (isCurrentRoute(currentRoute ?: "")) {
189                 ButtonDefaults.filledTonalButtonColors(
190                     containerColor =
191                         CustomAccentColorScheme.current.getAccentColorIfDefinedOrElse(
192                             /* fallback */ MaterialTheme.colorScheme.primary
193                         ),
194                     contentColor =
195                         CustomAccentColorScheme.current
196                             .getTextColorForAccentComponentsIfDefinedOrElse(
197                                 /* fallback */ MaterialTheme.colorScheme.onPrimary
198                             ),
199                 )
200             } else {
201                 ButtonDefaults.filledTonalButtonColors(
202                     containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
203                     contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
204                 )
205             },
206     ) {
207         buttonContent()
208     }
209 }
210 
211 /**
212  * Creates and positions any navigation buttons that have been registered for the
213  * [NAVIGATION_BAR_NAV_BUTTON] location. Accepts a maximum of two buttons.
214  */
215 @Composable
NavigationBarButtonsnull216 private fun NavigationBarButtons(modifier: Modifier) {
217     val featureManager = LocalFeatureManager.current
218     val categoryGridFeatureEnabled =
219         featureManager.isFeatureEnabled(CategoryGridFeature::class.java)
220     val searchFeatureEnabled = featureManager.isFeatureEnabled(SearchFeature::class.java)
221     Row(
222         // Consume the incoming modifier to get the correct positioning.
223         modifier =
224             if (categoryGridFeatureEnabled) {
225                 modifier.padding(start = 8.dp, end = 8.dp)
226             } else {
227                 modifier
228             },
229         horizontalArrangement = Arrangement.Center,
230     ) {
231         Row(
232             // Layout in individual buttons in a row, and space them evenly.
233             horizontalArrangement =
234                 Arrangement.spacedBy(
235                     MEASUREMENT_SPACER_SIZE,
236                     alignment = Alignment.CenterHorizontally,
237                 )
238         ) {
239             // Buttons are provided by registered features, so request for the features to fill
240             // this content.
241             LocalFeatureManager.current.composeLocation(
242                 Location.NAVIGATION_BAR_NAV_BUTTON,
243                 maxSlots = 2,
244                 modifier =
245                     if (searchFeatureEnabled && categoryGridFeatureEnabled) {
246                         Modifier.weight(1f)
247                     } else {
248                         Modifier // No modifier needed when search not enabled
249                     },
250             )
251         }
252     }
253 }
254 
255 /**
256  * Composable that provides Navigation Bar when inside an album that displays the album title and a
257  * back button
258  *
259  * @param modifier Modifier used to configure the layout of the navigation bar.
260  */
261 @Composable
NavigationBarForAlbumnull262 private fun NavigationBarForAlbum(modifier: Modifier) {
263     val navController = LocalNavController.current
264     val navBackStackEntry by navController.currentBackStackEntryAsState()
265     Row(modifier = modifier.fillMaxWidth()) {
266         val flow =
267             navBackStackEntry
268                 ?.savedStateHandle
269                 ?.getStateFlow<Group.Album?>(AlbumGridFeature.ALBUM_KEY, null)
270         val album = flow?.value
271         when (album) {
272             null -> {}
273             else -> {
274                 Row(verticalAlignment = Alignment.CenterVertically) {
275                     // back button
276                     IconButton(
277                         modifier =
278                             Modifier.width(MEASUREMENT_ICON_BUTTON_WIDTH)
279                                 .padding(horizontal = MEASUREMENT_ICON_BUTTON_OUTSIDE_PADDING),
280                         onClick = { navController.navigateToAlbumGrid() },
281                     ) {
282                         Icon(
283                             imageVector = Icons.AutoMirrored.Filled.ArrowBack,
284                             // For accessibility
285                             contentDescription = stringResource(R.string.photopicker_back_option),
286                             tint = MaterialTheme.colorScheme.onSurface,
287                         )
288                     }
289                     // Album name
290                     Text(
291                         text = album.displayName,
292                         overflow = TextOverflow.Ellipsis,
293                         maxLines = 1,
294                         style = MaterialTheme.typography.titleLarge,
295                         // Traversal index -1 forces TalkBack to focus on the album title first.
296                         modifier = Modifier.semantics { traversalIndex = -1f },
297                     )
298                 }
299             }
300         }
301         val featureManager = LocalFeatureManager.current
302         val overFlowMenuEnabled =
303             remember(featureManager) {
304                 featureManager.isFeatureEnabled(OverflowMenuFeature::class.java)
305             }
306         Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.End) {
307             if (overFlowMenuEnabled) {
308                 featureManager.composeLocation(
309                     Location.OVERFLOW_MENU,
310                     modifier = Modifier.width(MEASUREMENT_ICON_BUTTON_WIDTH),
311                 )
312             } else {
313                 Spacer(Modifier.width(MEASUREMENT_ICON_BUTTON_WIDTH))
314             }
315         }
316     }
317 }
318 
319 /**
320  * Composable that provides Navigation Bar when inside a group displays the album or media set title
321  * and a back button
322  *
323  * @param modifier Modifier used to configure the layout of the navigation bar.
324  */
325 @Composable
NavigationBarForGroupnull326 private fun NavigationBarForGroup(modifier: Modifier) {
327     val navController = LocalNavController.current
328     val navBackStackEntry by navController.currentBackStackEntryAsState()
329     Row(modifier = modifier.fillMaxWidth()) {
330         val flow =
331             navBackStackEntry
332                 ?.savedStateHandle
333                 ?.getStateFlow<Group?>(CategoryGridFeature.GROUP_KEY, null)
334         val group = flow?.value
335         when (group) {
336             is Group.Album -> {
337                 Row(verticalAlignment = Alignment.CenterVertically) {
338                     // back button
339                     IconButton(
340                         modifier =
341                             Modifier.width(MEASUREMENT_ICON_BUTTON_WIDTH)
342                                 .padding(horizontal = MEASUREMENT_ICON_BUTTON_OUTSIDE_PADDING),
343                         onClick = { navController.navigateToCategoryGrid() },
344                     ) {
345                         Icon(
346                             imageVector = Icons.AutoMirrored.Filled.ArrowBack,
347                             // For accessibility
348                             contentDescription = stringResource(R.string.photopicker_back_option),
349                             tint = MaterialTheme.colorScheme.onSurface,
350                         )
351                     }
352                     // Album name
353                     Text(
354                         text = group.displayName,
355                         overflow = TextOverflow.Ellipsis,
356                         maxLines = 1,
357                         style = MaterialTheme.typography.titleLarge,
358                         // Traversal index -1 forces TalkBack to focus on the album title first.
359                         modifier = Modifier.semantics { traversalIndex = -1f },
360                     )
361                 }
362             }
363             is Group.Category -> {
364                 Row(verticalAlignment = Alignment.CenterVertically) {
365                     // back button
366                     IconButton(
367                         modifier =
368                             Modifier.width(MEASUREMENT_ICON_BUTTON_WIDTH)
369                                 .padding(horizontal = MEASUREMENT_ICON_BUTTON_OUTSIDE_PADDING),
370                         onClick = { navController.navigateToCategoryGrid() },
371                     ) {
372                         Icon(
373                             imageVector = Icons.AutoMirrored.Filled.ArrowBack,
374                             // For accessibility
375                             contentDescription = stringResource(R.string.photopicker_back_option),
376                             tint = MaterialTheme.colorScheme.onSurface,
377                         )
378                     }
379                     Text(
380                         text = group.displayName ?: "",
381                         overflow = TextOverflow.Ellipsis,
382                         maxLines = 1,
383                         style = MaterialTheme.typography.titleLarge,
384                         // Traversal index -1 forces TalkBack to focus on the mediaset title first.
385                         modifier = Modifier.semantics { traversalIndex = -1f },
386                     )
387                 }
388             }
389             is Group.MediaSet -> {
390                 Row(verticalAlignment = Alignment.CenterVertically) {
391                     // back button
392                     IconButton(
393                         modifier =
394                             Modifier.width(MEASUREMENT_ICON_BUTTON_WIDTH)
395                                 .padding(horizontal = MEASUREMENT_ICON_BUTTON_OUTSIDE_PADDING),
396                         onClick = { navController.navigateToMediaSetGrid() },
397                     ) {
398                         Icon(
399                             imageVector = Icons.AutoMirrored.Filled.ArrowBack,
400                             // For accessibility
401                             contentDescription = stringResource(R.string.photopicker_back_option),
402                             tint = MaterialTheme.colorScheme.onSurface,
403                         )
404                     }
405                     Text(
406                         text = group.displayName ?: "",
407                         overflow = TextOverflow.Ellipsis,
408                         maxLines = 1,
409                         style = MaterialTheme.typography.titleLarge,
410                         // Traversal index -1 forces TalkBack to focus on the mediaset title first.
411                         modifier = Modifier.semantics { traversalIndex = -1f },
412                     )
413                 }
414             }
415             else -> {}
416         }
417         val featureManager = LocalFeatureManager.current
418         val overFlowMenuEnabled =
419             remember(featureManager) {
420                 featureManager.isFeatureEnabled(OverflowMenuFeature::class.java)
421             }
422         Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.End) {
423             if (overFlowMenuEnabled) {
424                 featureManager.composeLocation(
425                     Location.OVERFLOW_MENU,
426                     modifier = Modifier.width(MEASUREMENT_ICON_BUTTON_WIDTH),
427                 )
428             } else {
429                 Spacer(Modifier.width(MEASUREMENT_ICON_BUTTON_WIDTH))
430             }
431         }
432     }
433 }
434 
435 /**
436  * A composable function that displays a Navigation Bar with an integrated search bar which is
437  * called when the search feature is enabled.
438  *
439  * This Navigation Bar also includes [PROFILE_SELECTOR] and [OVERFLOW_MENU]
440  *
441  * Navigation buttons are positioned below the search bar.
442  */
443 @Composable
NavigationBarWithSearchnull444 private fun NavigationBarWithSearch(modifier: Modifier, params: LocationParams) {
445     val featureManager = LocalFeatureManager.current
446     Column(
447         modifier = modifier,
448         verticalArrangement = Arrangement.Top,
449         horizontalAlignment = Alignment.Start,
450     ) {
451         Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
452             featureManager.composeLocation(
453                 Location.SEARCH_BAR,
454                 maxSlots = 1,
455                 modifier = Modifier.weight(1f),
456                 params,
457             )
458             featureManager.composeLocation(
459                 Location.PROFILE_SELECTOR,
460                 maxSlots = 1,
461                 modifier = Modifier.padding(start = 8.dp),
462             )
463             val overFlowMenuEnabled =
464                 remember(featureManager) {
465                     featureManager.isFeatureEnabled(OverflowMenuFeature::class.java)
466                 }
467             if (
468                 overFlowMenuEnabled &&
469                     LocalFeatureManager.current.getSizeOfLocationInRegistry(
470                         Location.OVERFLOW_MENU_ITEMS
471                     ) > 0
472             ) {
473                 Row(modifier = Modifier, horizontalArrangement = Arrangement.End) {
474                     featureManager.composeLocation(
475                         Location.OVERFLOW_MENU,
476                         modifier = Modifier.width(MEASUREMENT_ICON_BUTTON_WIDTH),
477                     )
478                 }
479             }
480         }
481         NavigationBarButtons(Modifier)
482     }
483 }
484 
485 /**
486  * A composable function that displays a default Navigation Bar. This includes a [PROFILE_SELECTOR]
487  * and [OVERFLOW_MENU] along with navigation buttons.
488  */
489 @Composable
BasicNavigationBarnull490 private fun BasicNavigationBar(modifier: Modifier) {
491     val featureManager = LocalFeatureManager.current
492     val profileSelectorEnabled =
493         remember(featureManager) {
494             featureManager.isFeatureEnabled(ProfileSelectorFeature::class.java)
495         }
496     val overFlowMenuEnabled =
497         remember(featureManager) {
498             featureManager.isFeatureEnabled(OverflowMenuFeature::class.java)
499         }
500     Row(modifier = modifier.fillMaxWidth()) {
501         if (profileSelectorEnabled) {
502             featureManager.composeLocation(
503                 Location.PROFILE_SELECTOR,
504                 maxSlots = 1,
505                 modifier = Modifier.padding(start = 8.dp).weight(1f),
506             )
507         } else {
508             Spacer(
509                 Modifier.width(MEASUREMENT_ICON_BUTTON_WIDTH)
510                     .padding(start = MEASUREMENT_ICON_BUTTON_OUTSIDE_PADDING)
511                     .weight(1f)
512             )
513         }
514         hideWhenState(
515             selector =
516                 object : StateSelector.AnimatedVisibilityInEmbedded {
517                     override val visible = LocalEmbeddedState.current?.isExpanded ?: false
518                     override val enter = expandVertically(animationSpec = standardDecelerate(150))
519                     override val exit = shrinkVertically(animationSpec = standardDecelerate(100))
520                 }
521         ) {
522             NavigationBarButtons(Modifier)
523         }
524         Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.End) {
525             if (overFlowMenuEnabled) {
526                 featureManager.composeLocation(
527                     Location.OVERFLOW_MENU,
528                     modifier = Modifier.width(MEASUREMENT_ICON_BUTTON_WIDTH),
529                 )
530             } else {
531                 Spacer(Modifier.width(MEASUREMENT_ICON_BUTTON_WIDTH))
532             }
533         }
534     }
535 }
536