diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt index eaa00a1ee51..be324de4522 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt @@ -49,6 +49,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.layout.ContentScale @@ -265,6 +268,8 @@ fun HomeContent( modifier: Modifier = Modifier, ) { val context = LocalContext.current + val searchFocusRequester = remember { FocusRequester() } + val fabFocusRequester = remember { FocusRequester() } with(homeStateHolder) { fun openWireHomeDestination(item: HomeDestination) { @@ -329,6 +334,11 @@ fun HomeContent( onOpenConversationFilter = { homeStateHolder.conversationsFilterBottomSheetState.show(Unit) }, + nextFocusRequester = if (currentNavigationItem.fab != null) { + fabFocusRequester + } else { + searchFocusRequester + }, ) } }, @@ -343,7 +353,14 @@ fun HomeContent( isSearchActive = searchBarState.isSearchActive, searchBarHint = stringResource(searchBar.hint), searchQueryTextState = searchBarState.searchQueryTextState, - onActiveChanged = searchBarState::searchActiveChanged, + onCloseSearchClicked = searchBarState::closeSearch, + onActiveChanged = { isFocused -> + if (isFocused) { + searchBarState.openSearch() + } + }, + externalFocusRequester = searchFocusRequester, + nextFocusRequester = homeStateHolder.emptySearchResultFocusRequester, ) } } @@ -388,30 +405,12 @@ fun HomeContent( enter = scaleIn(), exit = scaleOut(), ) { - var currentFab by remember { mutableStateOf(currentNavigationItem.fab ?: FabOptions.NewConversation) } - // to keep the fab during the exit animation, we need to keep last known (non-null) fab data - if (currentNavigationItem.fab != null) currentFab = currentNavigationItem.fab!! - - FloatingActionButton( - text = stringResource(currentFab.text), - icon = { - Image( - painter = painterResource(currentFab.icon), - contentDescription = stringResource(currentFab.contentDescription), - contentScale = ContentScale.FillBounds, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimary), - modifier = Modifier - .padding(start = dimensions().spacing4x, top = dimensions().spacing2x) - .size(dimensions().fabIconSize) - ) - }, - onClick = { - when (currentNavigationItem.fab) { - FabOptions.NewConversation -> onNewConversationClick() - FabOptions.NewMeeting -> homeStateHolder.newMeetingBottomSheetState.show(Unit) - else -> { /* no-op */ } - } - } + HomeFloatingActionButton( + currentNavigationItem = currentNavigationItem, + fabFocusRequester = fabFocusRequester, + searchFocusRequester = searchFocusRequester, + onNewConversationClick = onNewConversationClick, + onNewMeetingClick = { homeStateHolder.newMeetingBottomSheetState.show(Unit) } ) } } @@ -420,3 +419,45 @@ fun HomeContent( ) } } + +@Composable +private fun HomeFloatingActionButton( + currentNavigationItem: HomeDestination, + fabFocusRequester: FocusRequester, + searchFocusRequester: FocusRequester, + onNewConversationClick: () -> Unit, + onNewMeetingClick: () -> Unit, +) { + var currentFab by remember { mutableStateOf(currentNavigationItem.fab ?: FabOptions.NewConversation) } + // to keep the fab during the exit animation, we need to keep last known (non-null) fab data + currentNavigationItem.fab?.let { currentFab = it } + + FloatingActionButton( + text = stringResource(currentFab.text), + modifier = Modifier + .focusRequester(fabFocusRequester) + .focusProperties { + if (currentNavigationItem.searchBar != null) { + next = searchFocusRequester + } + }, + icon = { + Image( + painter = painterResource(currentFab.icon), + contentDescription = stringResource(currentFab.contentDescription), + contentScale = ContentScale.FillBounds, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimary), + modifier = Modifier + .padding(start = dimensions().spacing4x, top = dimensions().spacing2x) + .size(dimensions().fabIconSize) + ) + }, + onClick = { + when (currentNavigationItem.fab) { + FabOptions.NewConversation -> onNewConversationClick() + FabOptions.NewMeeting -> onNewMeetingClick() + else -> { /* no-op */ } + } + } + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt index 3f5dd5fe4a4..2d2cac9454b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.focus.FocusRequester import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import com.wire.android.navigation.HomeDestination @@ -60,6 +61,8 @@ class HomeStateHolder( private val conversationFilterState: ConversationFilterState, private val lazyListStateProvider: LazyListStateProvider, ) { + val emptySearchResultFocusRequester = FocusRequester() + val currentNavigationItem get() = currentNavigationItemState.value diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt index b91d978594f..b6b95114cce 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt @@ -20,6 +20,9 @@ package com.wire.android.ui.home import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -52,9 +55,12 @@ fun HomeTopBar( onHamburgerMenuClick: () -> Unit, onNavigateToSelfUserProfile: () -> Unit, onOpenConversationFilter: () -> Unit, + modifier: Modifier = Modifier, + nextFocusRequester: FocusRequester? = null, ) { WireCenterAlignedTopAppBar( title = title, + modifier = modifier, onNavigationPressed = onHamburgerMenuClick, navigationIconType = NavigationIconType.Menu, actions = { @@ -82,11 +88,15 @@ fun HomeTopBar( } UserProfileAvatar( avatarData = userAvatarData, - clickable = remember { + modifier = Modifier.focusProperties { + nextFocusRequester?.let { next = it } + }, + clickable = remember(openLabel, onNavigateToSelfUserProfile) { Clickable( enabled = true, - onClickDescription = openLabel - ) { onNavigateToSelfUserProfile() } + onClickDescription = openLabel, + onClick = onNavigateToSelfUserProfile + ) }, type = UserProfileAvatarType.WithIndicators.RegularUser( legalHoldIndicatorVisible = withLegalHoldIndicator diff --git a/app/src/main/kotlin/com/wire/android/ui/home/archive/ArchiveScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/archive/ArchiveScreen.kt index 9b113a771f6..eef6fce5d90 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/archive/ArchiveScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/archive/ArchiveScreen.kt @@ -41,6 +41,7 @@ fun ArchiveScreen(homeStateHolder: HomeStateHolder) { searchBarState = searchBarState, conversationsSource = ConversationsSource.ARCHIVE, lazyListState = lazyListStateFor(HomeDestination.Archive), + emptySearchResultFocusRequester = emptySearchResultFocusRequester, emptyListContent = { ArchiveEmptyContent() } ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt index 78a699c3aea..4d582ffa77c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt @@ -18,6 +18,7 @@ package com.wire.android.ui.home.conversationslist +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable @@ -26,6 +27,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.hilt.navigation.compose.hiltViewModel @@ -81,10 +85,12 @@ import com.wire.kalium.logic.data.user.UserId fun ConversationsScreenContent( navigator: Navigator, searchBarState: SearchBarState, + modifier: Modifier = Modifier, emptyListContent: @Composable (domain: String) -> Unit = {}, lazyListState: LazyListState = rememberLazyListState(), loadingListContent: @Composable () -> Unit = { LoadingListContent() }, conversationsSource: ConversationsSource = ConversationsSource.MAIN, + emptySearchResultFocusRequester: FocusRequester? = null, conversationListViewModel: ConversationListViewModel = when { LocalInspectionMode.current -> ConversationListViewModelPreview() else -> hiltViewModel( @@ -103,6 +109,9 @@ fun ConversationsScreenContent( val permissionPermanentlyDeniedDialogState = rememberVisibilityState() val context = LocalContext.current + val emptySearchResultModifier = emptySearchResultFocusRequester?.let { + Modifier.focusRequester(it) + } ?: Modifier LaunchedEffect(searchBarState.isSearchActive) { if (searchBarState.isSearchActive) { @@ -164,69 +173,77 @@ fun ConversationsScreenContent( } } - when (val state = conversationListViewModel.conversationListState) { - is ConversationListState.Paginated -> { - val lazyPagingItems = state.conversations.collectAsLazyPagingItemsWithLifecycle() - searchBarState.searchVisibleChanged(lazyPagingItems.itemCount > 0 || searchBarState.isSearchActive) - when { - // when conversation list is not yet fetched, show loading indicator - lazyPagingItems.isLoading() -> loadingListContent() - // when there is at least one conversation - lazyPagingItems.itemCount > 0 -> ConversationList( - lazyPagingConversations = lazyPagingItems, - lazyListState = lazyListState, - onOpenConversation = onOpenConversation, - onEditConversation = onEditConversationItem, - onOpenUserProfile = onOpenUserProfile, - onJoinCall = onJoinCall, - onAudioPermissionPermanentlyDenied = { - permissionPermanentlyDeniedDialogState.show( - PermissionPermanentlyDeniedDialogState.Visible( - R.string.app_permission_dialog_title, - R.string.call_permission_dialog_description + Box(modifier = modifier) { + when (val state = conversationListViewModel.conversationListState) { + is ConversationListState.Paginated -> { + val lazyPagingItems = state.conversations.collectAsLazyPagingItemsWithLifecycle() + searchBarState.searchVisibleChanged(lazyPagingItems.itemCount > 0 || searchBarState.isSearchActive) + when { + // when conversation list is not yet fetched, show loading indicator + lazyPagingItems.isLoading() -> loadingListContent() + // when there is at least one conversation + lazyPagingItems.itemCount > 0 -> ConversationList( + lazyPagingConversations = lazyPagingItems, + lazyListState = lazyListState, + onOpenConversation = onOpenConversation, + onEditConversation = onEditConversationItem, + onOpenUserProfile = onOpenUserProfile, + onJoinCall = onJoinCall, + onAudioPermissionPermanentlyDenied = { + permissionPermanentlyDeniedDialogState.show( + PermissionPermanentlyDeniedDialogState.Visible( + R.string.app_permission_dialog_title, + R.string.call_permission_dialog_description + ) ) - ) - }, - onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, - onStopCurrentAudio = onStopCurrentAudio, - onBrowsePublicChannels = { - navigator.navigate(NavigationCommand(BrowseChannelsScreenDestination)) - } - ) - // when there is no conversation in any folder - searchBarState.isSearchActive -> SearchConversationsEmptyContent(onNewConversationClicked = onNewConversationClicked) - else -> emptyListContent(state.domain) + }, + onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, + onStopCurrentAudio = onStopCurrentAudio, + onBrowsePublicChannels = { + navigator.navigate(NavigationCommand(BrowseChannelsScreenDestination)) + } + ) + // when there is no conversation in any folder + searchBarState.isSearchActive -> SearchConversationsEmptyContent( + onNewConversationClicked = onNewConversationClicked, + modifier = emptySearchResultModifier + ) + else -> emptyListContent(state.domain) + } } - } - is ConversationListState.NotPaginated -> { - val hasConversations = state.conversations.isNotEmpty() && state.conversations.any { it.value.isNotEmpty() } - searchBarState.searchVisibleChanged(isSearchVisible = hasConversations || searchBarState.isSearchActive) - when { - // when conversation list is not yet fetched, show loading indicator - state.isLoading -> loadingListContent() - // when there is at least one conversation in any folder - hasConversations -> ConversationList( - lazyListState = lazyListState, - conversationListItems = state.conversations, - onOpenConversation = onOpenConversation, - onEditConversation = onEditConversationItem, - onOpenUserProfile = onOpenUserProfile, - onJoinCall = onJoinCall, - onAudioPermissionPermanentlyDenied = { - permissionPermanentlyDeniedDialogState.show( - PermissionPermanentlyDeniedDialogState.Visible( - R.string.app_permission_dialog_title, - R.string.call_permission_dialog_description + is ConversationListState.NotPaginated -> { + val hasConversations = state.conversations.isNotEmpty() && state.conversations.any { it.value.isNotEmpty() } + searchBarState.searchVisibleChanged(isSearchVisible = hasConversations || searchBarState.isSearchActive) + when { + // when conversation list is not yet fetched, show loading indicator + state.isLoading -> loadingListContent() + // when there is at least one conversation in any folder + hasConversations -> ConversationList( + lazyListState = lazyListState, + conversationListItems = state.conversations, + onOpenConversation = onOpenConversation, + onEditConversation = onEditConversationItem, + onOpenUserProfile = onOpenUserProfile, + onJoinCall = onJoinCall, + onAudioPermissionPermanentlyDenied = { + permissionPermanentlyDeniedDialogState.show( + PermissionPermanentlyDeniedDialogState.Visible( + R.string.app_permission_dialog_title, + R.string.call_permission_dialog_description + ) ) - ) - }, - onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, - onStopCurrentAudio = onStopCurrentAudio - ) - // when there is no conversation in any folder - searchBarState.isSearchActive -> SearchConversationsEmptyContent(onNewConversationClicked = onNewConversationClicked) - else -> emptyListContent(state.domain) + }, + onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, + onStopCurrentAudio = onStopCurrentAudio + ) + // when there is no conversation in any folder + searchBarState.isSearchActive -> SearchConversationsEmptyContent( + onNewConversationClicked = onNewConversationClicked, + modifier = emptySearchResultModifier + ) + else -> emptyListContent(state.domain) + } } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt index b3262ad6a71..44dc640d499 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt @@ -66,6 +66,7 @@ fun AllConversationsScreen( is ConversationFilter.Channels -> ConversationsSource.CHANNELS }, lazyListState = lazyListStateFor(HomeDestination.Conversations, filter), + emptySearchResultFocusRequester = emptySearchResultFocusRequester, emptyListContent = { ConversationsEmptyContent(filter = filter, navigator = navigator) } ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/search/SearchConversationsEmptyContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/search/SearchConversationsEmptyContent.kt index ecf8fd2afb2..94695c68f33 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/search/SearchConversationsEmptyContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/search/SearchConversationsEmptyContent.kt @@ -39,11 +39,14 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes @Composable -fun SearchConversationsEmptyContent(onNewConversationClicked: () -> Unit, modifier: Modifier = Modifier) { +fun SearchConversationsEmptyContent( + onNewConversationClicked: () -> Unit, + modifier: Modifier = Modifier, +) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, - modifier = modifier.fillMaxSize() + modifier = Modifier.fillMaxSize() ) { VerticalSpace.x8() Column( @@ -72,6 +75,7 @@ fun SearchConversationsEmptyContent(onNewConversationClicked: () -> Unit, modifi fillMaxWidth = false, minSize = dimensions().buttonSmallMinSize, minClickableSize = dimensions().buttonMinClickableSize, + modifier = modifier, onClick = onNewConversationClicked ) } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/Extensions.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/Extensions.kt index bfc8e7abf15..048b38cbf27 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/Extensions.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/Extensions.kt @@ -31,6 +31,11 @@ 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.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics @@ -109,6 +114,23 @@ fun Modifier.clickable(clickable: Clickable?) = clickable?.let { } } ?: this +fun Modifier.onEscapeOrBackKey( + enabled: Boolean = true, + onKeyPressed: () -> Unit, +): Modifier = onPreviewKeyEvent { event -> + val isEscapeOrBack = event.key == Key.Escape || event.key == Key.Back + val isKeyPressOrRelease = event.type == KeyEventType.KeyDown || event.type == KeyEventType.KeyUp + + if (enabled && isKeyPressOrRelease && isEscapeOrBack) { + if (event.type == KeyEventType.KeyDown) { + onKeyPressed() + } + true + } else { + false + } +} + private class SingleClickHandler { private companion object { diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/SearchBar.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/SearchBar.kt index 218f62bc401..3dcc0e39a25 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/SearchBar.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/SearchBar.kt @@ -49,24 +49,29 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.util.PreviewMultipleThemes +@Suppress("LongParameterList") @Composable fun SearchBarInput( placeholderText: String, leadingIcon: @Composable () -> Unit, textState: TextFieldState, modifier: Modifier = Modifier, + inputModifier: Modifier = Modifier, + clearButtonModifier: Modifier = Modifier, placeholderTextStyle: TextStyle = LocalTextStyle.current, placeholderAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, textStyle: TextStyle = LocalTextStyle.current, textFieldState: WireTextFieldState = WireTextFieldState.Default, isLoading: Boolean = false, + inputEnabled: Boolean = true, semanticDescription: String? = null, onTap: (() -> Unit)? = null ) { WireTextField( modifier = modifier, + inputModifier = inputModifier, textState = textState, state = textFieldState, leadingIcon = { @@ -95,7 +100,9 @@ fun SearchBarInput( ) } IconButton( - modifier = Modifier.padding(start = dimensions().spacing12x), + modifier = Modifier + .padding(start = dimensions().spacing12x) + .then(clearButtonModifier), onClick = textState::clearText, ) { Icon( @@ -112,6 +119,7 @@ fun SearchBarInput( placeholderAlignment = placeholderAlignment, placeholderText = placeholderText, lineLimits = TextFieldLineLimits.SingleLine, + enabled = inputEnabled, semanticDescription = semanticDescription, onTap = onTap, ) diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt index 97256d7b64b..417c694be26 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt @@ -65,6 +65,7 @@ import com.wire.android.util.PreviewMultipleThemes fun WireTextField( textState: TextFieldState, modifier: Modifier = Modifier, + inputModifier: Modifier = Modifier, placeholderText: String? = null, labelText: String? = null, labelMandatoryIcon: Boolean = false, @@ -92,6 +93,7 @@ fun WireTextField( onTap: (() -> Unit)? = null, testTag: String = String.EMPTY, validateKeyboardOptions: Boolean = true, + enabled: Boolean = state !is WireTextFieldState.Disabled, ) { if (validateKeyboardOptions) { assert( @@ -146,10 +148,10 @@ fun WireTextField( outputTransformation = outputTransformation, scrollState = scrollState, readOnly = state is WireTextFieldState.ReadOnly, - enabled = state !is WireTextFieldState.Disabled, + enabled = enabled, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), interactionSource = interactionSource, - modifier = textFieldModifier, + modifier = textFieldModifier.then(inputModifier), decorator = decorator, onTextLayout = onTextLayout( textState, diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchTopBar.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchTopBar.kt index aefcb1416f7..922a570133a 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchTopBar.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchTopBar.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.common.topappbar.search -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.focusable @@ -43,19 +42,25 @@ import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusEvent -import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import com.wire.android.ui.common.R import com.wire.android.ui.common.SearchBarInput import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.onEscapeOrBackKey import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme @@ -74,29 +79,44 @@ fun SearchTopBar( searchBarDescription: String? = null, onCloseSearchClicked: (() -> Unit)? = null, onActiveChanged: (isActive: Boolean) -> Unit = {}, + externalFocusRequester: FocusRequester? = null, + nextFocusRequester: FocusRequester? = null, bottomContent: @Composable ColumnScope.() -> Unit = {}, textFieldState: WireTextFieldState = WireTextFieldState.Default, onTap: (() -> Unit)? = null, ) { val interactionSource = remember { MutableInteractionSource() } - val focusManager: FocusManager = LocalFocusManager.current - val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val localFocusRequester = remember { FocusRequester() } + val focusRequester = resolveFocusRequester(externalFocusRequester, localFocusRequester) + val backButtonFocusRequester = remember { FocusRequester() } + val clearButtonFocusRequester = remember { FocusRequester() } + val hasSearchQuery by remember { derivedStateOf { searchQueryTextState.text.isNotBlank() } } + val resolvedBackIconContentDescription = resolveBackIconContentDescription(backIconContentDescription) - fun setActive(isActive: Boolean) { - if (isActive) { - focusRequester.requestFocus() - } else { - focusManager.clearFocus(true) - if (shouldClearTextOnClearFocus) { - searchQueryTextState.clearText() - } + fun showKeyboard() { + keyboardController?.show() + } + + fun clearSearchState() { + if (shouldClearTextOnClearFocus) { + searchQueryTextState.clearText() } } - LaunchedEffect(isSearchActive) { - setActive(isSearchActive) + fun closeSearchInput() { + keyboardController?.hide() + clearSearchState() + onCloseSearchClicked?.invoke() ?: onActiveChanged(false) } + SearchFocusEffect( + isSearchActive = isSearchActive, + focusRequester = focusRequester, + onShowKeyboard = ::showKeyboard, + onClearSearchState = ::clearSearchState + ) + val placeholderAlignment by animateHorizontalAlignmentAsState( targetAlignment = if (isSearchActive) Alignment.CenterStart else Alignment.Center ) @@ -107,55 +127,248 @@ fun SearchTopBar( .fillMaxWidth() .background(MaterialTheme.wireColorScheme.background) ) { - SearchBarInput( - placeholderText = searchBarHint, - semanticDescription = searchBarDescription, - textState = searchQueryTextState, - isLoading = isLoading, - textFieldState = textFieldState, - leadingIcon = { - AnimatedContent(!isSearchActive && !keepBackButtonVisible, label = "") { showSearchIcon -> - if (showSearchIcon) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.size(dimensions().buttonCircleMinSize) - ) { - Icon( - painter = painterResource(R.drawable.ic_search), - contentDescription = null, - tint = MaterialTheme.wireColorScheme.onBackground, - ) - } - } else { - IconButton( - onClick = { onCloseSearchClicked?.invoke() ?: setActive(false) }, - modifier = Modifier.size(dimensions().buttonCircleMinSize) - ) { - Icon( - painter = painterResource(R.drawable.ic_arrow_back), - contentDescription = backIconContentDescription, - tint = MaterialTheme.wireColorScheme.onBackground, - ) - } - } - } + if (isSearchActive) { + ActiveSearchBarInput( + placeholderText = searchBarHint, + semanticDescription = searchBarDescription, + textState = searchQueryTextState, + isLoading = isLoading, + textFieldState = textFieldState, + placeholderAlignment = placeholderAlignment, + interactionSource = interactionSource, + focusRequester = focusRequester, + backButtonFocusRequester = backButtonFocusRequester, + clearButtonFocusRequester = clearButtonFocusRequester, + nextFocusRequester = nextFocusRequester, + hasSearchQuery = hasSearchQuery, + backIconContentDescription = resolvedBackIconContentDescription, + onCloseSearchInput = ::closeSearchInput, + onActiveChanged = onActiveChanged, + ) + } else { + InactiveSearchBarInput( + placeholderText = searchBarHint, + semanticDescription = searchBarDescription, + textState = searchQueryTextState, + isLoading = isLoading, + textFieldState = textFieldState, + placeholderAlignment = placeholderAlignment, + interactionSource = interactionSource, + focusRequester = focusRequester, + keepBackButtonVisible = keepBackButtonVisible, + backIconContentDescription = resolvedBackIconContentDescription, + onCloseSearchInput = ::closeSearchInput, + onTap = onTap, + onActiveChanged = onActiveChanged, + onShowKeyboard = ::showKeyboard + ) + } + bottomContent() + } +} + +private fun resolveFocusRequester( + externalFocusRequester: FocusRequester?, + localFocusRequester: FocusRequester, +): FocusRequester = externalFocusRequester ?: localFocusRequester + +@Composable +private fun resolveBackIconContentDescription(backIconContentDescription: String?): String = + backIconContentDescription ?: stringResource(R.string.content_description_back_button) + +@Composable +private fun SearchFocusEffect( + isSearchActive: Boolean, + focusRequester: FocusRequester, + onShowKeyboard: () -> Unit, + onClearSearchState: () -> Unit, +) { + LaunchedEffect(isSearchActive) { + if (isSearchActive) { + withFrameNanos { } + focusRequester.requestFocus() + onShowKeyboard() + } else { + onClearSearchState() + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun ActiveSearchBarInput( + placeholderText: String, + semanticDescription: String?, + textState: TextFieldState, + isLoading: Boolean, + textFieldState: WireTextFieldState, + placeholderAlignment: Alignment.Horizontal, + interactionSource: MutableInteractionSource, + focusRequester: FocusRequester, + backButtonFocusRequester: FocusRequester, + clearButtonFocusRequester: FocusRequester, + nextFocusRequester: FocusRequester?, + hasSearchQuery: Boolean, + backIconContentDescription: String, + onCloseSearchInput: () -> Unit, + onActiveChanged: (Boolean) -> Unit, +) { + SearchBarInput( + placeholderText = placeholderText, + semanticDescription = semanticDescription, + textState = textState, + isLoading = isLoading, + textFieldState = textFieldState, + leadingIcon = { + SearchBackButton( + focusRequester = backButtonFocusRequester, + nextFocusRequester = focusRequester, + contentDescription = backIconContentDescription, + onClick = onCloseSearchInput + ) + }, + placeholderTextStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Start), + placeholderAlignment = placeholderAlignment, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Start), + interactionSource = interactionSource, + inputEnabled = true, + clearButtonModifier = Modifier + .focusRequester(clearButtonFocusRequester) + .focusProperties { + previous = focusRequester + nextFocusRequester?.let { next = it } }, - placeholderTextStyle = LocalTextStyle.current.copy( - textAlign = if (!isSearchActive) TextAlign.Center else TextAlign.Start - ), - placeholderAlignment = placeholderAlignment, - textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Start), - interactionSource = interactionSource, - onTap = onTap, - modifier = Modifier - .padding(dimensions().spacing8x) - .focusable(enabled = true) - .focusRequester(focusRequester) - .onFocusEvent { - onActiveChanged(it.isFocused) + onTap = null, + modifier = Modifier + .onEscapeOrBackKey( + enabled = true, + onKeyPressed = onCloseSearchInput + ) + .padding(dimensions().spacing8x), + inputModifier = Modifier + .focusRequester(focusRequester) + .focusProperties { + previous = backButtonFocusRequester + if (hasSearchQuery) { + next = clearButtonFocusRequester + } else { + nextFocusRequester?.let { next = it } } + } + .onEscapeOrBackKey( + enabled = true, + onKeyPressed = onCloseSearchInput + ) + .onFocusEvent { + if (it.isFocused) { + onActiveChanged(true) + } + } + ) +} + +@Composable +private fun InactiveSearchBarInput( + placeholderText: String, + semanticDescription: String?, + textState: TextFieldState, + isLoading: Boolean, + textFieldState: WireTextFieldState, + placeholderAlignment: Alignment.Horizontal, + interactionSource: MutableInteractionSource, + focusRequester: FocusRequester, + keepBackButtonVisible: Boolean, + backIconContentDescription: String, + onCloseSearchInput: () -> Unit, + onTap: (() -> Unit)?, + onActiveChanged: (Boolean) -> Unit, + onShowKeyboard: () -> Unit, +) { + fun activateSearch() { + onTap?.invoke() + onActiveChanged(true) + onShowKeyboard() + } + + SearchBarInput( + placeholderText = placeholderText, + semanticDescription = semanticDescription, + textState = textState, + isLoading = isLoading, + textFieldState = textFieldState, + leadingIcon = { + if (keepBackButtonVisible) { + SearchBackButton( + focusRequester = null, + nextFocusRequester = null, + contentDescription = backIconContentDescription, + onClick = onCloseSearchInput + ) + } else { + SearchIcon() + } + }, + placeholderTextStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + placeholderAlignment = placeholderAlignment, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Start), + interactionSource = interactionSource, + inputEnabled = true, + onTap = ::activateSearch, + modifier = Modifier + .padding(dimensions().spacing8x) + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) { + activateSearch() + } + } + .semantics { + semanticDescription?.let { contentDescription = it } + } + .focusable(), + inputModifier = Modifier.onFocusEvent { + if (it.isFocused) { + activateSearch() + } + } + ) +} + +@Composable +private fun SearchBackButton( + focusRequester: FocusRequester?, + nextFocusRequester: FocusRequester?, + contentDescription: String, + onClick: () -> Unit, +) { + IconButton( + onClick = onClick, + modifier = Modifier + .size(dimensions().buttonCircleMinSize) + .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) + .focusProperties { + nextFocusRequester?.let { next = it } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = contentDescription, + tint = MaterialTheme.wireColorScheme.onBackground, + ) + } +} + +@Composable +private fun SearchIcon() { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(dimensions().buttonCircleMinSize) + ) { + Icon( + painter = painterResource(R.drawable.ic_search), + contentDescription = null, + tint = MaterialTheme.wireColorScheme.onBackground, ) - bottomContent() } }