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