From 86c7d0417627847b73ec65964e7548ec1a77e5e4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 4 Feb 2026 17:48:02 +0100 Subject: [PATCH] Create HorizontalFloatingToolbar wrapper in our components. --- .../android/features/home/impl/HomeView.kt | 54 +++-- .../home/impl/components/HomeTopBar.kt | 10 - .../components/HorizontalFloatingToolbar.kt | 223 ++++++++++++++++++ 3 files changed, 252 insertions(+), 35 deletions(-) create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index a57d8c8278..240307414b 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -13,7 +13,6 @@ package io.element.android.features.home.impl import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding @@ -25,14 +24,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FabPosition import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset -import androidx.compose.material3.HorizontalFloatingToolbar -import androidx.compose.material3.IconButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalLayoutDirection @@ -65,6 +61,9 @@ import io.element.android.libraries.androidutils.throttler.FirstThrottler import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.FloatingActionButton +import io.element.android.libraries.designsystem.theme.components.HorizontalFloatingToolbar +import io.element.android.libraries.designsystem.theme.components.HorizontalFloatingToolbarItem +import io.element.android.libraries.designsystem.theme.components.HorizontalFloatingToolbarSeparator import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost @@ -189,12 +188,10 @@ private fun HomeScaffold( onAccountSwitch = { state.eventSink(HomeEvent.SwitchToAccount(it)) }, - onCreateSpace = onCreateSpaceClick, scrollBehavior = scrollBehavior, displayFilters = state.displayRoomListFilters, filtersState = roomListState.filtersState, spaceFiltersState = roomListState.spaceFiltersState, - canCreateSpaces = state.homeSpacesState.canCreateSpaces, canReportBug = state.canReportBug, modifier = Modifier.hazeEffect( state = hazeState, @@ -226,12 +223,21 @@ private fun HomeScaffold( state.eventSink(HomeEvent.SelectHomeNavigationBarItem(item)) } }, - floatingActionButton = { - when (state.currentHomeNavigationBarItem) { - HomeNavigationBarItem.Chats -> HomeFloatingActionButton(onStartChatClick, CommonStrings.action_create_a_room) - HomeNavigationBarItem.Spaces -> HomeFloatingActionButton(onCreateSpaceClick, CommonStrings.action_create_space) + floatingActionButton = when (state.currentHomeNavigationBarItem) { + HomeNavigationBarItem.Chats -> { + { + HomeFloatingActionButton(onStartChatClick, CommonStrings.action_create_room) + } } - } + HomeNavigationBarItem.Spaces -> if (state.homeSpacesState.canCreateSpaces) { + { + HomeFloatingActionButton(onCreateSpaceClick, CommonStrings.action_create_space) + } + } else { + // No FAB for spaces if we cannot create spaces + null + } + }, ) } }, @@ -311,33 +317,31 @@ private fun HomeFloatingActionButton( } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun HomeBottomBar( currentHomeNavigationBarItem: HomeNavigationBarItem, onItemClick: (HomeNavigationBarItem) -> Unit, - floatingActionButton: @Composable () -> Unit, + floatingActionButton: (@Composable () -> Unit)?, modifier: Modifier = Modifier, ) { HorizontalFloatingToolbar( - expanded = true, floatingActionButton = floatingActionButton, modifier = modifier .padding(bottom = ScreenOffset) .zIndex(1f), ) { - HomeNavigationBarItem.entries.forEach { item -> - val isSelected = currentHomeNavigationBarItem == item - IconButton( - onClick = { onItemClick(item) }, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon( - imageVector = item.icon(isSelected), - contentDescription = stringResource(item.labelRes), - ) - } + HomeNavigationBarItem.entries.forEachIndexed { index, item -> + if (index > 0) { + HorizontalFloatingToolbarSeparator() } + val isSelected = currentHomeNavigationBarItem == item + HorizontalFloatingToolbarItem( + icon = item.icon(isSelected), + tooltipLabel = stringResource(item.labelRes), + isSelected = isSelected, + onClick = { onItemClick(item) }, + ) } } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt index afba2e1b82..c6fed9b2f5 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt @@ -87,9 +87,7 @@ fun HomeTopBar( onMenuActionClick: (RoomListMenuAction) -> Unit, onOpenSettings: () -> Unit, onAccountSwitch: (SessionId) -> Unit, - onCreateSpace: () -> Unit, scrollBehavior: TopAppBarScrollBehavior, - canCreateSpaces: Boolean, canReportBug: Boolean, displayFilters: Boolean, filtersState: RoomListFiltersState, @@ -346,8 +344,6 @@ internal fun HomeTopBarPreview() = ElementPreview { onOpenSettings = {}, onAccountSwitch = {}, onToggleSearch = {}, - onCreateSpace = {}, - canCreateSpaces = true, canReportBug = true, displayFilters = true, filtersState = aRoomListFiltersState(), @@ -392,8 +388,6 @@ internal fun HomeTopBarSpacesPreview() = ElementPreview { onOpenSettings = {}, onAccountSwitch = {}, onToggleSearch = {}, - onCreateSpace = {}, - canCreateSpaces = true, canReportBug = true, displayFilters = false, filtersState = aRoomListFiltersState(), @@ -415,8 +409,6 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview { onOpenSettings = {}, onAccountSwitch = {}, onToggleSearch = {}, - onCreateSpace = {}, - canCreateSpaces = true, canReportBug = true, displayFilters = true, filtersState = aRoomListFiltersState(), @@ -438,8 +430,6 @@ internal fun HomeTopBarMultiAccountPreview() = ElementPreview { onOpenSettings = {}, onAccountSwitch = {}, onToggleSearch = {}, - onCreateSpace = {}, - canCreateSpaces = true, canReportBug = true, displayFilters = true, filtersState = aRoomListFiltersState(), diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt new file mode 100644 index 0000000000..8cdb320d30 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FloatingToolbarColors +import androidx.compose.material3.FloatingToolbarDefaults +import androidx.compose.material3.FloatingToolbarHorizontalFabPosition +import androidx.compose.material3.FloatingToolbarScrollBehavior +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.atomic.atoms.CounterAtom +import io.element.android.libraries.designsystem.components.tooltip.PlainTooltip +import io.element.android.libraries.designsystem.components.tooltip.TooltipBox +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +/** + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=4457-1136 + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun HorizontalFloatingToolbar( + modifier: Modifier = Modifier, + expanded: Boolean = true, + floatingActionButton: (@Composable () -> Unit)? = null, + colors: FloatingToolbarColors = FloatingToolbarDefaults.standardFloatingToolbarColors().copy( + toolbarContainerColor = ElementTheme.colors.bgSubtleSecondary, + ), + contentPadding: PaddingValues = PaddingValues( + vertical = 8.dp, + horizontal = 12.dp, + ), + scrollBehavior: FloatingToolbarScrollBehavior? = null, + shape: Shape = FloatingToolbarDefaults.ContainerShape, + leadingContent: @Composable (RowScope.() -> Unit)? = null, + trailingContent: @Composable (RowScope.() -> Unit)? = null, + floatingActionButtonPosition: FloatingToolbarHorizontalFabPosition = + FloatingToolbarHorizontalFabPosition.End, + animationSpec: FiniteAnimationSpec = FloatingToolbarDefaults.animationSpec(), + expandedShadowElevation: Dp = 8.dp, + collapsedShadowElevation: Dp = if (floatingActionButton == null) { + FloatingToolbarDefaults.ContainerCollapsedElevation + } else { + FloatingToolbarDefaults.ContainerCollapsedElevationWithFab + }, + content: @Composable RowScope.() -> Unit, +) { + if (floatingActionButton == null) { + androidx.compose.material3.HorizontalFloatingToolbar( + expanded = expanded, + modifier = modifier, + colors = colors, + contentPadding = contentPadding, + scrollBehavior = scrollBehavior, + shape = shape, + leadingContent = leadingContent, + trailingContent = trailingContent, + expandedShadowElevation = expandedShadowElevation, + collapsedShadowElevation = collapsedShadowElevation, + content = content, + ) + } else { + androidx.compose.material3.HorizontalFloatingToolbar( + expanded = expanded, + floatingActionButton = floatingActionButton, + modifier = modifier, + colors = colors, + contentPadding = contentPadding, + scrollBehavior = scrollBehavior, + shape = shape, + floatingActionButtonPosition = floatingActionButtonPosition, + animationSpec = animationSpec, + expandedShadowElevation = expandedShadowElevation, + collapsedShadowElevation = collapsedShadowElevation, + content = content, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HorizontalFloatingToolbarItem( + icon: ImageVector, + tooltipLabel: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + counter: Int? = null, + forceRenderingTooltip: Boolean = false, +) { + TooltipBox( + positionProvider = + TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Above + ), + tooltip = { PlainTooltip { Text(tooltipLabel) } }, + state = rememberTooltipState( + initialIsVisible = forceRenderingTooltip, + ), + modifier = modifier, + ) { + val colors = if (isSelected) { + IconButtonDefaults.filledIconButtonColors().copy( + containerColor = ElementTheme.colors.bgCanvasDefault, + contentColor = ElementTheme.colors.iconPrimary, + ) + } else { + IconButtonDefaults.filledIconButtonColors().copy( + containerColor = Color.Transparent, + contentColor = ElementTheme.colors.iconSecondary, + ) + } + Box { + FilledIconButton( + modifier = Modifier.widthIn(min = 56.dp), + colors = colors, + onClick = onClick, + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = icon, + contentDescription = tooltipLabel, + ) + } + if (counter != null) { + CounterAtom( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 6.dp, end = 3.dp), + count = counter, + textStyle = ElementTheme.typography.fontBodyXsMedium, + ) + } + } + } +} + +@Composable +fun HorizontalFloatingToolbarSeparator(modifier: Modifier = Modifier) { + Spacer(modifier = modifier.width(16.dp)) +} + +@PreviewsDayNight +@Composable +internal fun HorizontalFloatingToolbarPreview() = ElementPreview { + ContentToPreview( + floatingActionButton = { + FloatingActionButton( + onClick = {}, + ) { + Icon( + imageVector = CompoundIcons.Plus(), + contentDescription = null, + ) + } + } + ) +} + +@PreviewsDayNight +@Composable +internal fun HorizontalFloatingToolbarNoFabPreview() = ElementPreview { + ContentToPreview( + floatingActionButton = null, + ) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun ContentToPreview( + floatingActionButton: (@Composable () -> Unit)?, +) { + HorizontalFloatingToolbar( + modifier = Modifier.padding(28.dp), + floatingActionButton = floatingActionButton, + ) { + listOf( + CompoundIcons.ChatSolid(), + CompoundIcons.Space(), + ).forEachIndexed { index, icon -> + if (index > 0) { + HorizontalFloatingToolbarSeparator() + } + HorizontalFloatingToolbarItem( + icon = icon, + tooltipLabel = "Label", + isSelected = index == 0, + counter = if (index == 0) 6 else null, + forceRenderingTooltip = true, + onClick = { }, + ) + } + } +}