[Room list] Search & menu improvements (#356)
* Remove settings menu item, start splitting search UI. Also, add `applyIf` and `circularReveal` modifiers. * Split UI & logic for room list search * Suppress `composed` warning, improve its debuggability * Add content description to the user's avatar, fix window insets. Also, remove unused `SearchRoomListTopBar`.
This commit is contained in:
parent
4a689267f5
commit
e2b2dbf59f
31 changed files with 534 additions and 199 deletions
1
changelog.d/354.feature
Normal file
1
changelog.d/354.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Improve room list search and general UI
|
||||||
|
|
@ -71,8 +71,8 @@ class NetworkMonitorImpl @Inject constructor(
|
||||||
|
|
||||||
private fun listenToConnectionChanges() {
|
private fun listenToConnectionChanges() {
|
||||||
val request = NetworkRequest.Builder()
|
val request = NetworkRequest.Builder()
|
||||||
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
// .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
// .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
.build()
|
.build()
|
||||||
connectivityManager.registerNetworkCallback(request, callback)
|
connectivityManager.registerNetworkCallback(request, callback)
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,5 @@ sealed interface RoomListEvents {
|
||||||
data class UpdateFilter(val newFilter: String) : RoomListEvents
|
data class UpdateFilter(val newFilter: String) : RoomListEvents
|
||||||
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
|
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
|
||||||
object DismissRequestVerificationPrompt : RoomListEvents
|
object DismissRequestVerificationPrompt : RoomListEvents
|
||||||
|
object ToggleSearchResults : RoomListEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ class RoomListPresenter @Inject constructor(
|
||||||
|
|
||||||
Timber.v("RoomSummaries size = ${roomSummaries.size}")
|
Timber.v("RoomSummaries size = ${roomSummaries.size}")
|
||||||
|
|
||||||
|
val mappedRoomSummaries: MutableState<ImmutableList<RoomListRoomSummary>> = remember { mutableStateOf(persistentListOf()) }
|
||||||
val filteredRoomSummaries: MutableState<ImmutableList<RoomListRoomSummary>> = remember {
|
val filteredRoomSummaries: MutableState<ImmutableList<RoomListRoomSummary>> = remember {
|
||||||
mutableStateOf(persistentListOf())
|
mutableStateOf(persistentListOf())
|
||||||
}
|
}
|
||||||
|
|
@ -101,41 +102,51 @@ class RoomListPresenter @Inject constructor(
|
||||||
derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified && !verificationPromptDismissed }
|
derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified && !verificationPromptDismissed }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var displaySearchResults by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
fun handleEvents(event: RoomListEvents) {
|
fun handleEvents(event: RoomListEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is RoomListEvents.UpdateFilter -> filter = event.newFilter
|
is RoomListEvents.UpdateFilter -> filter = event.newFilter
|
||||||
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
|
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
|
||||||
RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true
|
RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true
|
||||||
|
RoomListEvents.ToggleSearchResults -> {
|
||||||
|
if (displaySearchResults) {
|
||||||
|
filter = ""
|
||||||
|
}
|
||||||
|
displaySearchResults =! displaySearchResults
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(roomSummaries, filter) {
|
LaunchedEffect(roomSummaries, filter) {
|
||||||
filteredRoomSummaries.value = updateFilteredRoomSummaries(roomSummaries, filter)
|
mappedRoomSummaries.value = if (roomSummaries.isEmpty()) {
|
||||||
|
RoomListRoomSummaryPlaceholders.createFakeList(16).toImmutableList()
|
||||||
|
} else {
|
||||||
|
mapRoomSummaries(roomSummaries).toImmutableList()
|
||||||
|
}
|
||||||
|
filteredRoomSummaries.value = updateFilteredRoomSummaries(mappedRoomSummaries.value, filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
|
val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
|
||||||
|
|
||||||
return RoomListState(
|
return RoomListState(
|
||||||
matrixUser = matrixUser.value,
|
matrixUser = matrixUser.value,
|
||||||
roomList = filteredRoomSummaries.value,
|
roomList = mappedRoomSummaries.value,
|
||||||
filter = filter,
|
filter = filter,
|
||||||
|
filteredRoomList = filteredRoomSummaries.value,
|
||||||
displayVerificationPrompt = displayVerificationPrompt,
|
displayVerificationPrompt = displayVerificationPrompt,
|
||||||
snackbarMessage = snackbarMessage,
|
snackbarMessage = snackbarMessage,
|
||||||
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
||||||
displayInvites = invites.isNotEmpty(),
|
displayInvites = invites.isNotEmpty(),
|
||||||
|
displaySearchResults = displaySearchResults,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun updateFilteredRoomSummaries(roomSummaries: List<RoomSummary>?, filter: String): ImmutableList<RoomListRoomSummary> {
|
private fun updateFilteredRoomSummaries(mappedRoomSummaries: ImmutableList<RoomListRoomSummary>, filter: String): ImmutableList<RoomListRoomSummary> {
|
||||||
if (roomSummaries.isNullOrEmpty()) {
|
return when {
|
||||||
return RoomListRoomSummaryPlaceholders.createFakeList(16).toImmutableList()
|
filter.isEmpty() -> emptyList()
|
||||||
}
|
else -> mappedRoomSummaries.filter { it.name.contains(filter, ignoreCase = true) }
|
||||||
val mappedRoomSummaries = mapRoomSummaries(roomSummaries)
|
|
||||||
return if (filter.isEmpty()) {
|
|
||||||
mappedRoomSummaries
|
|
||||||
} else {
|
|
||||||
mappedRoomSummaries.filter { it.name.contains(filter, ignoreCase = true) }
|
|
||||||
}.toImmutableList()
|
}.toImmutableList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,12 @@ import kotlinx.collections.immutable.ImmutableList
|
||||||
data class RoomListState(
|
data class RoomListState(
|
||||||
val matrixUser: MatrixUser?,
|
val matrixUser: MatrixUser?,
|
||||||
val roomList: ImmutableList<RoomListRoomSummary>,
|
val roomList: ImmutableList<RoomListRoomSummary>,
|
||||||
val filter: String,
|
val filter: String?,
|
||||||
|
val filteredRoomList: ImmutableList<RoomListRoomSummary>,
|
||||||
val displayVerificationPrompt: Boolean,
|
val displayVerificationPrompt: Boolean,
|
||||||
val hasNetworkConnection: Boolean,
|
val hasNetworkConnection: Boolean,
|
||||||
val snackbarMessage: SnackbarMessage?,
|
val snackbarMessage: SnackbarMessage?,
|
||||||
val displayInvites: Boolean,
|
val displayInvites: Boolean,
|
||||||
|
val displaySearchResults: Boolean,
|
||||||
val eventSink: (RoomListEvents) -> Unit
|
val eventSink: (RoomListEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
|
||||||
aRoomListState().copy(snackbarMessage = SnackbarMessage(StringR.string.common_verification_complete)),
|
aRoomListState().copy(snackbarMessage = SnackbarMessage(StringR.string.common_verification_complete)),
|
||||||
aRoomListState().copy(hasNetworkConnection = false),
|
aRoomListState().copy(hasNetworkConnection = false),
|
||||||
aRoomListState().copy(displayInvites = true),
|
aRoomListState().copy(displayInvites = true),
|
||||||
|
aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()),
|
||||||
|
aRoomListState().copy(displaySearchResults = true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,10 +45,12 @@ internal fun aRoomListState() = RoomListState(
|
||||||
matrixUser = MatrixUser(id = UserId("@id:domain"), username = "User#1", avatarData = AvatarData("@id:domain", "U")),
|
matrixUser = MatrixUser(id = UserId("@id:domain"), username = "User#1", avatarData = AvatarData("@id:domain", "U")),
|
||||||
roomList = aRoomListRoomSummaryList(),
|
roomList = aRoomListRoomSummaryList(),
|
||||||
filter = "filter",
|
filter = "filter",
|
||||||
|
filteredRoomList = aRoomListRoomSummaryList(),
|
||||||
hasNetworkConnection = true,
|
hasNetworkConnection = true,
|
||||||
snackbarMessage = null,
|
snackbarMessage = null,
|
||||||
displayVerificationPrompt = false,
|
displayVerificationPrompt = false,
|
||||||
displayInvites = false,
|
displayInvites = false,
|
||||||
|
displaySearchResults = false,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,8 @@ import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorVi
|
||||||
import io.element.android.features.roomlist.impl.components.RoomListTopBar
|
import io.element.android.features.roomlist.impl.components.RoomListTopBar
|
||||||
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
|
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
|
||||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||||
|
import io.element.android.features.roomlist.impl.search.RoomListSearchResultContent
|
||||||
|
import io.element.android.features.roomlist.impl.search.RoomListSearchResultView
|
||||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
|
@ -91,15 +93,27 @@ fun RoomListView(
|
||||||
onCreateRoomClicked: () -> Unit = {},
|
onCreateRoomClicked: () -> Unit = {},
|
||||||
onInvitesClicked: () -> Unit = {},
|
onInvitesClicked: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
RoomListContent(
|
Column(modifier = modifier) {
|
||||||
state = state,
|
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
|
||||||
modifier = modifier,
|
Box {
|
||||||
onRoomClicked = onRoomClicked,
|
RoomListContent(
|
||||||
onOpenSettings = onOpenSettings,
|
state = state,
|
||||||
onVerifyClicked = onVerifyClicked,
|
onRoomClicked = onRoomClicked,
|
||||||
onCreateRoomClicked = onCreateRoomClicked,
|
onOpenSettings = onOpenSettings,
|
||||||
onInvitesClicked = onInvitesClicked,
|
onVerifyClicked = onVerifyClicked,
|
||||||
)
|
onCreateRoomClicked = onCreateRoomClicked,
|
||||||
|
onInvitesClicked = onInvitesClicked,
|
||||||
|
)
|
||||||
|
// This overlaid view will only be visible when state.displaySearchResults is true
|
||||||
|
RoomListSearchResultView(
|
||||||
|
state = state,
|
||||||
|
onRoomClicked = onRoomClicked,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
|
|
@ -163,16 +177,14 @@ fun RoomListContent(
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
Column {
|
RoomListTopBar(
|
||||||
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
|
matrixUser = state.matrixUser,
|
||||||
RoomListTopBar(
|
areSearchResultsDisplayed = state.displaySearchResults,
|
||||||
matrixUser = state.matrixUser,
|
onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
|
||||||
filter = state.filter,
|
onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) },
|
||||||
onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSettings = onOpenSettings,
|
scrollBehavior = scrollBehavior,
|
||||||
scrollBehavior = scrollBehavior,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
content = { padding ->
|
content = { padding ->
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -306,7 +318,7 @@ internal fun PreviewRequestVerificationHeaderDark() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun RoomListRoomSummary.contentType() = isPlaceholder
|
internal fun RoomListRoomSummary.contentType() = isPlaceholder
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -322,3 +334,11 @@ internal fun RoomListViewDarkPreview(@PreviewParameter(RoomListStateProvider::cl
|
||||||
private fun ContentToPreview(state: RoomListState) {
|
private fun ContentToPreview(state: RoomListState) {
|
||||||
RoomListView(state)
|
RoomListView(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun RoomListSearchResultContentPreview() {
|
||||||
|
ElementPreviewLight {
|
||||||
|
RoomListSearchResultContent(state = aRoomListState(), onRoomClicked = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,57 +20,40 @@ package io.element.android.features.roomlist.impl.components
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.material.ContentAlpha
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import io.element.android.features.roomlist.impl.R
|
import io.element.android.features.roomlist.impl.R
|
||||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.form.textFieldState
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar
|
import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextField
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
|
||||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomListTopBar(
|
fun RoomListTopBar(
|
||||||
matrixUser: MatrixUser?,
|
matrixUser: MatrixUser?,
|
||||||
filter: String,
|
areSearchResultsDisplayed: Boolean,
|
||||||
onFilterChanged: (String) -> Unit,
|
onFilterChanged: (String) -> Unit,
|
||||||
|
onToggleSearch: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
scrollBehavior: TopAppBarScrollBehavior,
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -79,124 +62,26 @@ fun RoomListTopBar(
|
||||||
tag = "RoomListScreen",
|
tag = "RoomListScreen",
|
||||||
msg = "TopBar"
|
msg = "TopBar"
|
||||||
)
|
)
|
||||||
var searchWidgetStateIsOpened by rememberSaveable { mutableStateOf(false) }
|
|
||||||
|
|
||||||
fun closeFilter() {
|
fun closeFilter() {
|
||||||
onFilterChanged("")
|
onFilterChanged("")
|
||||||
searchWidgetStateIsOpened = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(enabled = searchWidgetStateIsOpened) {
|
BackHandler(enabled = areSearchResultsDisplayed) {
|
||||||
closeFilter()
|
closeFilter()
|
||||||
|
onToggleSearch()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchWidgetStateIsOpened) {
|
DefaultRoomListTopBar(
|
||||||
SearchRoomListTopBar(
|
matrixUser = matrixUser,
|
||||||
text = filter,
|
onOpenSettings = onOpenSettings,
|
||||||
onFilterChanged = onFilterChanged,
|
onSearchClicked = onToggleSearch,
|
||||||
onCloseClicked = ::closeFilter,
|
scrollBehavior = scrollBehavior,
|
||||||
scrollBehavior = scrollBehavior,
|
modifier = modifier,
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
DefaultRoomListTopBar(
|
|
||||||
matrixUser = matrixUser,
|
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onSearchClicked = {
|
|
||||||
searchWidgetStateIsOpened = true
|
|
||||||
},
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SearchRoomListTopBar(
|
|
||||||
text: String,
|
|
||||||
scrollBehavior: TopAppBarScrollBehavior,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onFilterChanged: (String) -> Unit = {},
|
|
||||||
onCloseClicked: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
var filterState by textFieldState(stateValue = text)
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
TopAppBar(
|
|
||||||
modifier = modifier
|
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
|
||||||
title = {
|
|
||||||
TextField(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.focusRequester(focusRequester),
|
|
||||||
value = filterState,
|
|
||||||
textStyle = TextStyle(
|
|
||||||
fontSize = 17.sp
|
|
||||||
),
|
|
||||||
onValueChange = {
|
|
||||||
filterState = it
|
|
||||||
onFilterChanged(it)
|
|
||||||
},
|
|
||||||
placeholder = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = StringR.string.action_search),
|
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = ContentAlpha.medium)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
singleLine = true,
|
|
||||||
trailingIcon = {
|
|
||||||
if (text.isNotEmpty()) {
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
onFilterChanged("")
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Close,
|
|
||||||
contentDescription = "clear",
|
|
||||||
tint = MaterialTheme.colorScheme.onBackground
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
onCloseClicked()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.ArrowBack,
|
|
||||||
contentDescription = "close",
|
|
||||||
tint = MaterialTheme.colorScheme.onBackground
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
windowInsets = WindowInsets(0.dp)
|
|
||||||
)
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
focusRequester.requestFocus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
internal fun SearchRoomListTopBarLightPreview() = ElementPreviewLight { SearchRoomListTopBarPreview() }
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
internal fun SearchRoomListTopBarDarkPreview() = ElementPreviewDark { SearchRoomListTopBarPreview() }
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SearchRoomListTopBarPreview() {
|
|
||||||
SearchRoomListTopBar(
|
|
||||||
text = "Hello",
|
|
||||||
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DefaultRoomListTopBar(
|
private fun DefaultRoomListTopBar(
|
||||||
matrixUser: MatrixUser?,
|
matrixUser: MatrixUser?,
|
||||||
|
|
@ -216,8 +101,8 @@ private fun DefaultRoomListTopBar(
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
if (matrixUser != null) {
|
if (matrixUser != null) {
|
||||||
IconButton(onClick = {}) {
|
IconButton(onClick = onOpenSettings) {
|
||||||
Avatar(matrixUser.avatarData)
|
Avatar(matrixUser.avatarData, contentDescription = stringResource(StringR.string.common_settings))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -225,12 +110,7 @@ private fun DefaultRoomListTopBar(
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onSearchClicked
|
onClick = onSearchClicked
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Search, contentDescription = "search")
|
Icon(Icons.Default.Search, contentDescription = stringResource(StringR.string.action_search))
|
||||||
}
|
|
||||||
IconButton(
|
|
||||||
onClick = onOpenSettings
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
|
|
@ -246,6 +126,7 @@ internal fun DefaultRoomListTopBarLightPreview() = ElementPreviewLight { Default
|
||||||
@Composable
|
@Composable
|
||||||
internal fun DefaultRoomListTopBarDarkPreview() = ElementPreviewDark { DefaultRoomListTopBarPreview() }
|
internal fun DefaultRoomListTopBarDarkPreview() = ElementPreviewDark { DefaultRoomListTopBarPreview() }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DefaultRoomListTopBarPreview() {
|
private fun DefaultRoomListTopBarPreview() {
|
||||||
DefaultRoomListTopBar(
|
DefaultRoomListTopBar(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.roomlist.impl.search
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.Velocity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.features.roomlist.impl.RoomListEvents
|
||||||
|
import io.element.android.features.roomlist.impl.RoomListState
|
||||||
|
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
|
||||||
|
import io.element.android.features.roomlist.impl.contentType
|
||||||
|
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||||
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.applyIf
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
|
import io.element.android.libraries.designsystem.utils.copy
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.ui.strings.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun RoomListSearchResultView(
|
||||||
|
state: RoomListState,
|
||||||
|
onRoomClicked: (RoomId) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = state.displaySearchResults,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.applyIf(state.displaySearchResults, ifTrue = {
|
||||||
|
// Disable input interaction to underlying views
|
||||||
|
pointerInput(Unit) {}
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
if (state.displaySearchResults) {
|
||||||
|
RoomListSearchResultContent(state = state, onRoomClicked = onRoomClicked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun RoomListSearchResultContent(
|
||||||
|
state: RoomListState,
|
||||||
|
onRoomClicked: (RoomId) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val borderColor = MaterialTheme.colorScheme.tertiary
|
||||||
|
val strokeWidth = 1.dp
|
||||||
|
fun onBackButtonPressed() {
|
||||||
|
state.eventSink(RoomListEvents.ToggleSearchResults)
|
||||||
|
}
|
||||||
|
fun onRoomClicked(room: RoomListRoomSummary) {
|
||||||
|
if (room.roomId == null) return
|
||||||
|
onRoomClicked(room.roomId)
|
||||||
|
}
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
modifier = Modifier.drawBehind {
|
||||||
|
drawLine(
|
||||||
|
color = borderColor,
|
||||||
|
start = Offset(0f, size.height),
|
||||||
|
end = Offset(size.width, size.height),
|
||||||
|
strokeWidth = strokeWidth.value
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = { BackButton(onClick = ::onBackButtonPressed) },
|
||||||
|
title = {
|
||||||
|
val filter = state.filter.orEmpty()
|
||||||
|
val focusRequester = FocusRequester()
|
||||||
|
TextField(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
value = filter,
|
||||||
|
onValueChange = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
|
||||||
|
colors = TextFieldDefaults.textFieldColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
errorIndicatorColor = Color.Transparent,
|
||||||
|
disabledIndicatorColor = Color.Transparent
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
if (filter.isNotEmpty()) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
state.eventSink(RoomListEvents.UpdateFilter(""))
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.action_cancel)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(state.displaySearchResults) {
|
||||||
|
if (state.displaySearchResults) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
windowInsets = TopAppBarDefaults.windowInsets.copy(top = 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
val lazyListState = rememberLazyListState()
|
||||||
|
val visibleRange by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val layoutInfo = lazyListState.layoutInfo
|
||||||
|
val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0
|
||||||
|
val size = layoutInfo.visibleItemsInfo.size
|
||||||
|
firstItemIndex until firstItemIndex + size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val nestedScrollConnection = remember {
|
||||||
|
object : NestedScrollConnection {
|
||||||
|
override suspend fun onPostFling(
|
||||||
|
consumed: Velocity,
|
||||||
|
available: Velocity
|
||||||
|
): Velocity {
|
||||||
|
state.eventSink(RoomListEvents.UpdateVisibleRange(visibleRange))
|
||||||
|
return super.onPostFling(consumed, available)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.nestedScroll(nestedScrollConnection),
|
||||||
|
state = lazyListState,
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = state.filteredRoomList,
|
||||||
|
contentType = { room -> room.contentType() },
|
||||||
|
) { room ->
|
||||||
|
RoomSummaryRow(room = room, onClick = ::onRoomClicked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -112,6 +112,8 @@ class RoomListPresenterTests {
|
||||||
withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t"))
|
withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t"))
|
||||||
val withFilterState = awaitItem()
|
val withFilterState = awaitItem()
|
||||||
Truth.assertThat(withFilterState.filter).isEqualTo("t")
|
Truth.assertThat(withFilterState.filter).isEqualTo("t")
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,17 +170,18 @@ class RoomListPresenterTests {
|
||||||
val loadedState = awaitItem()
|
val loadedState = awaitItem()
|
||||||
// Test filtering with result
|
// Test filtering with result
|
||||||
loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3)))
|
loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3)))
|
||||||
|
skipItems(1) // Filter update
|
||||||
val withNotFilteredRoomState = awaitItem()
|
val withNotFilteredRoomState = awaitItem()
|
||||||
Truth.assertThat(withNotFilteredRoomState.filter).isEqualTo(A_ROOM_NAME.substring(0, 3))
|
Truth.assertThat(withNotFilteredRoomState.filter).isEqualTo(A_ROOM_NAME.substring(0, 3))
|
||||||
Truth.assertThat(withNotFilteredRoomState.roomList.size).isEqualTo(1)
|
Truth.assertThat(withNotFilteredRoomState.filteredRoomList.size).isEqualTo(1)
|
||||||
Truth.assertThat(withNotFilteredRoomState.roomList.first())
|
Truth.assertThat(withNotFilteredRoomState.filteredRoomList.first())
|
||||||
.isEqualTo(aRoomListRoomSummary)
|
.isEqualTo(aRoomListRoomSummary)
|
||||||
// Test filtering without result
|
// Test filtering without result
|
||||||
withNotFilteredRoomState.eventSink.invoke(RoomListEvents.UpdateFilter("tada"))
|
withNotFilteredRoomState.eventSink.invoke(RoomListEvents.UpdateFilter("tada"))
|
||||||
skipItems(1) // Filter update
|
skipItems(1) // Filter update
|
||||||
val withFilteredRoomState = awaitItem()
|
val withFilteredRoomState = awaitItem()
|
||||||
Truth.assertThat(withFilteredRoomState.filter).isEqualTo("tada")
|
Truth.assertThat(withFilteredRoomState.filter).isEqualTo("tada")
|
||||||
Truth.assertThat(withFilteredRoomState.roomList).isEmpty()
|
Truth.assertThat(withFilteredRoomState.filteredRoomList).isEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,11 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Avatar(avatarData: AvatarData, modifier: Modifier = Modifier) {
|
fun Avatar(
|
||||||
|
avatarData: AvatarData,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentDescription: String? = null,
|
||||||
|
) {
|
||||||
val commonModifier = modifier
|
val commonModifier = modifier
|
||||||
.size(avatarData.size.dp)
|
.size(avatarData.size.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
|
|
@ -54,6 +58,7 @@ fun Avatar(avatarData: AvatarData, modifier: Modifier = Modifier) {
|
||||||
ImageAvatar(
|
ImageAvatar(
|
||||||
avatarData = avatarData,
|
avatarData = avatarData,
|
||||||
modifier = commonModifier,
|
modifier = commonModifier,
|
||||||
|
contentDescription = contentDescription,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -62,13 +67,14 @@ fun Avatar(avatarData: AvatarData, modifier: Modifier = Modifier) {
|
||||||
private fun ImageAvatar(
|
private fun ImageAvatar(
|
||||||
avatarData: AvatarData,
|
avatarData: AvatarData,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
contentDescription: String? = null,
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = avatarData,
|
model = avatarData,
|
||||||
onError = {
|
onError = {
|
||||||
Timber.e("TAG", "Error $it\n${it.result}", it.result.throwable)
|
Timber.e("TAG", "Error $it\n${it.result}", it.result.throwable)
|
||||||
},
|
},
|
||||||
contentDescription = null,
|
contentDescription = contentDescription,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
placeholder = debugPlaceholderAvatar(),
|
placeholder = debugPlaceholderAvatar(),
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -89,7 +95,7 @@ private fun InitialsAvatar(
|
||||||
end = Offset(100f, 0f)
|
end = Offset(100f, 0f)
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier.background(brush = initialsGradient)
|
modifier.background(brush = initialsGradient),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.designsystem.modifiers
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
|
import androidx.compose.ui.platform.debugInspectorInfo
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the [ifTrue] modifier when the [condition] is true, [ifFalse] otherwise.
|
||||||
|
*/
|
||||||
|
@SuppressLint("UnnecessaryComposedModifier") // It's actually necessary due to the `@Composable` lambdas
|
||||||
|
fun Modifier.applyIf(
|
||||||
|
condition: Boolean,
|
||||||
|
ifTrue: @Composable Modifier.() -> Modifier,
|
||||||
|
ifFalse: @Composable (Modifier.() -> Modifier)? = null
|
||||||
|
): Modifier =
|
||||||
|
composed(
|
||||||
|
inspectorInfo = debugInspectorInfo {
|
||||||
|
name = "applyIf"
|
||||||
|
value = condition
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
condition -> then(ifTrue(Modifier))
|
||||||
|
ifFalse != null -> then(ifFalse(Modifier))
|
||||||
|
else -> this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.designsystem.modifiers
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.updateTransition
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||||
|
import androidx.compose.ui.platform.debugInspectorInfo
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
// Note: these modifiers come from https://gist.github.com/darvld/eb3844474baf2f3fc6d3ab44a4b4b5f8
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A modifier that clips the composable content using an animated circle. The circle will
|
||||||
|
* expand/shrink with an animation whenever [visible] changes.
|
||||||
|
*
|
||||||
|
* For more fine-grained control over the transition, see this method's overload, which allows passing
|
||||||
|
* a [State] object to control the progress of the reveal animation.
|
||||||
|
*
|
||||||
|
* By default, the circle is centered in the content, but custom positions may be specified using
|
||||||
|
* [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).*/
|
||||||
|
fun Modifier.circularReveal(
|
||||||
|
visible: Boolean,
|
||||||
|
showScrim: Boolean = false,
|
||||||
|
revealFrom: Offset = Offset(0.5f, 0.5f),
|
||||||
|
): Modifier = composed(
|
||||||
|
factory = {
|
||||||
|
val factor = updateTransition(visible, label = "Visibility")
|
||||||
|
.animateFloat(label = "revealFactor") { if (it) 1f else 0f }
|
||||||
|
|
||||||
|
circularReveal(factor, showScrim, revealFrom)
|
||||||
|
},
|
||||||
|
inspectorInfo = debugInspectorInfo {
|
||||||
|
name = "circularReveal"
|
||||||
|
properties["visible"] = visible
|
||||||
|
properties["revealFrom"] = revealFrom
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A modifier that clips the composable content using a circular shape. The radius of the circle
|
||||||
|
* will be determined by the [transitionProgress].
|
||||||
|
*
|
||||||
|
* The values of the progress should be between 0 and 1.
|
||||||
|
*
|
||||||
|
* By default, the circle is centered in the content, but custom positions may be specified using
|
||||||
|
* [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).
|
||||||
|
* */
|
||||||
|
fun Modifier.circularReveal(
|
||||||
|
transitionProgress: State<Float>,
|
||||||
|
showScrim: Boolean = false,
|
||||||
|
revealFrom: Offset = Offset(0.5f, 0.5f)
|
||||||
|
): Modifier {
|
||||||
|
return drawWithCache {
|
||||||
|
val path = Path()
|
||||||
|
val center = revealFrom.mapTo(size)
|
||||||
|
val radius = calculateRadius(revealFrom, size)
|
||||||
|
val scrimColor = if (showScrim)
|
||||||
|
Color.Gray
|
||||||
|
else
|
||||||
|
Color.Transparent
|
||||||
|
|
||||||
|
path.addOval(Rect(center, radius * transitionProgress.value))
|
||||||
|
|
||||||
|
onDrawWithContent {
|
||||||
|
if (showScrim) {
|
||||||
|
drawRect(scrimColor, alpha = transitionProgress.value * 0.75f)
|
||||||
|
}
|
||||||
|
clipPath(path) { this@onDrawWithContent.drawContent() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Offset.mapTo(size: Size): Offset {
|
||||||
|
return Offset(x * size.width, y * size.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateRadius(normalizedOrigin: Offset, size: Size) = with(normalizedOrigin) {
|
||||||
|
val x = (if (x > 0.5f) x else 1 - x) * size.width
|
||||||
|
val y = (if (y > 0.5f) y else 1 - y) * size.height
|
||||||
|
|
||||||
|
sqrt(x * x + y * y)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.designsystem.utils
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WindowInsets.copy(
|
||||||
|
top: Int? = null,
|
||||||
|
right: Int? = null,
|
||||||
|
bottom: Int? = null,
|
||||||
|
left: Int? = null
|
||||||
|
): WindowInsets {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val direction = LocalLayoutDirection.current
|
||||||
|
return WindowInsets(
|
||||||
|
top = top ?: this.getTop(density),
|
||||||
|
right = right ?: this.getRight(density, direction),
|
||||||
|
bottom = bottom ?: this.getBottom(density),
|
||||||
|
left = left ?: this.getLeft(density, direction)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8bca09418758a20493dae2e73a747449af8a448ad3a3cc4c5aae2e08a425f3fb
|
oid sha256:b5f2b24a19ca49b3e6e34ccd65d2bdba72d0384104931bda92e191959d58c5c3
|
||||||
size 13464
|
size 12697
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e50325c75193e47958862ea9cb515d7c84d2c47a00b01256fc244319780c107f
|
oid sha256:c4fa32eb24a0cc51b9b19c6f24a7d3d59aae65f1f30a43b1a6d70b3ed3e2154d
|
||||||
size 12425
|
size 11716
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:97b11203623c0c98da88dfedf85cb80d1f35cc55da570e12061b1385691bf1f0
|
||||||
|
size 27758
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0fc272268179409483fc5fad89aa00714a6811c63e478b5c696d4ab0338ce6bf
|
oid sha256:3cdb131c68de1fce5a3319151e39148e9f3a71c7bc3984e89ec0a80abf0f7288
|
||||||
size 37781
|
size 37044
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d729c75d73d1837b365c8332b6e4203cb2492f6c5c4af06741a4bd2e818daebb
|
oid sha256:8b265978c4db7b266fd07d56364eccafba1cd765ed9bf6d5a03b1584e173ba6a
|
||||||
size 60667
|
size 59936
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0fc272268179409483fc5fad89aa00714a6811c63e478b5c696d4ab0338ce6bf
|
oid sha256:3cdb131c68de1fce5a3319151e39148e9f3a71c7bc3984e89ec0a80abf0f7288
|
||||||
size 37781
|
size 37044
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:56a253a7981823ca0fc7b653f9e83d29d1f259fea43ca4cee5760fa863306f2c
|
oid sha256:ccf989dac7fad3cc70443d96e1ebd519463a6559ed0795ae2a1ffbaf91bdfe7c
|
||||||
size 39847
|
size 39092
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:2b1222e1ef2d0739caa410540bf74fea681ef2e05bb04b976e50f1fe5256d613
|
oid sha256:c2a23141c6cc8aa6e7e5f0757bda4d1117bf7f752412ccc4649ea560113b3e3f
|
||||||
size 39762
|
size 39030
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9095546c30bb5bc9800c852456fa9cd82d14e873a5e1488d29496af088e951da
|
||||||
|
size 4882
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8cfe321535e1ce223a0460435123dc59e74c436bdd8696cf4bdb2169f511832b
|
||||||
|
size 28541
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:43b69859fa3ee38d2b7f7415b87738db65dc6dac3d2fabddc1f1346b0b64932b
|
oid sha256:a2a261b30866af95b856ee1e7d6ac2cbe2d638cf80277645f361b043d2f94e60
|
||||||
size 37329
|
size 36658
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f61d4fc4d5af166e1761f004e7d80934eef24a582c96c590c769ed1fb13041a4
|
oid sha256:0cdd1fad4b3db78fb8599785334366e3bfbaf5dead7990f396f8752862ab9e98
|
||||||
size 59489
|
size 58987
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:43b69859fa3ee38d2b7f7415b87738db65dc6dac3d2fabddc1f1346b0b64932b
|
oid sha256:a2a261b30866af95b856ee1e7d6ac2cbe2d638cf80277645f361b043d2f94e60
|
||||||
size 37329
|
size 36658
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1bf9529823b4e04261b1528b2309c549f1d3aad80c542059f11c1b26a2979b04
|
oid sha256:1ba3d8a5cfbd102dc6df8f511eb14663170a6d550ee5e65702bc1f3fce3efd14
|
||||||
size 39359
|
size 38657
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ebb15a58e5d3497b2819f5a6b6fde88962f01cc2ae02d4b8cda0e45e1dde677f
|
oid sha256:d427b479f9eb6227bb92aecd997bab97a735ef85dd279a575d777e754effd258
|
||||||
size 39314
|
size 38639
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d463ab6d045cc310973c5ff900cf4d9ae04e93cb1c7eac3f9b2aa0ea9b827cee
|
||||||
|
size 4815
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:97b11203623c0c98da88dfedf85cb80d1f35cc55da570e12061b1385691bf1f0
|
||||||
|
size 27758
|
||||||
Loading…
Add table
Add a link
Reference in a new issue