From 0d3653413e711b3c18a479a36f6693eb86c603aa Mon Sep 17 00:00:00 2001 From: NUmeroAndDev Date: Sun, 2 Jul 2023 16:54:13 +0900 Subject: [PATCH 1/2] Implemented Timetable Tab --- .../sessions/component/TimetableTab.kt | 227 ++++++++++++++++++ .../sessions/section/TimetableSheet.kt | 75 ++++-- 2 files changed, 283 insertions(+), 19 deletions(-) create mode 100644 feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableTab.kt diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableTab.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableTab.kt new file mode 100644 index 000000000..2b227f40c --- /dev/null +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableTab.kt @@ -0,0 +1,227 @@ +package io.github.droidkaigi.confsched2023.sessions.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Tab +import androidx.compose.material3.TabPosition +import androidx.compose.material3.TabRow +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlin.math.roundToInt + +@Composable +fun TimetableTab( + day: Int, + selected: Boolean, + onClick: () -> Unit, + scrollState: TimetableTabScrollState, + modifier: Modifier = Modifier, +) { + Tab( + selected = selected, + onClick = onClick, + content = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(vertical = 4.dp), + ) { + Text( + // TODO: Fix to reflect the date from the data + text = "Day$day", + style = MaterialTheme.typography.labelMedium, + ) + Text( + // TODO: Fix to reflect the date from the data + text = "${day + 13}", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .graphicsLayer { + alpha = (1 - scrollState.progress * 2).coerceAtLeast(0f) + } + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout( + placeable.width, + placeable.height - (placeable.height * scrollState.progress).roundToInt(), + ) { + placeable.placeRelative(0, 0) + } + }, + ) + } + }, + selectedContentColor = MaterialTheme.colorScheme.onPrimary, + unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.heightIn(min = minTabHeight), + ) +} + +@Composable +fun TimetableTabIndicator( + modifier: Modifier = Modifier, +) { + Box( + modifier + .zIndex(-1f) + .padding(horizontal = 8.dp) + .fillMaxSize() + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(50), + ), + ) +} + +@Composable +fun TimetableTabRow( + scrollState: TimetableTabScrollState, + selectedTabIndex: Int, + modifier: Modifier = Modifier, + indicator: @Composable (tabPositions: List) -> Unit = @Composable { tabPositions -> + if (selectedTabIndex < tabPositions.size) { + TimetableTabIndicator( + Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]), + ) + } + }, + tabs: @Composable () -> Unit, +) { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = modifier.height(maxTabRowHeight - ((maxTabRowHeight - minTabRowHeight) * scrollState.progress)), + divider = {}, + indicator = indicator, + tabs = tabs, + ) +} + +@Composable +fun rememberTimetableTabScrollState(): TimetableTabScrollState { + val offsetLimit = LocalDensity.current.run { + (maxTabRowHeight - minTabRowHeight).toPx() + } + return rememberSaveable(saver = TimetableTabScrollState.Saver) { + TimetableTabScrollState( + initialOffsetLimit = -offsetLimit, + ) + } +} + +@Stable +class TimetableTabScrollState( + initialOffsetLimit: Float = 0f, + initialScrollOffset: Float = 0f, +) { + // This value will be like -418.0 + private val scrollOffsetLimit by mutableStateOf(initialOffsetLimit) + + /** + * If progress is 0f, the tabs is fully expanded. + * If progress is scrollOffsetLimit, the tabs is fully expanded. + */ + val progress: Float + get() = scrollOffset / scrollOffsetLimit + + private val _scrollOffset = mutableStateOf(initialScrollOffset) + + private var scrollOffset: Float + get() = _scrollOffset.value + set(newOffset) { + _scrollOffset.value = newOffset.coerceIn( + minimumValue = scrollOffsetLimit, + maximumValue = 0f, + ) + } + + private val isTabExpandable: Boolean + get() = scrollOffset > scrollOffsetLimit + + private val isTabCollapsing: Boolean + get() = scrollOffset != 0f + + val nestedScrollConnection = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + return onPreScrollScreen(available) + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + return onPostScrollScreen(available) + } + } + + /** + * This function returns the consumed offset. + */ + private fun onPreScrollScreen(availableScrollOffset: Offset): Offset { + if (availableScrollOffset.y >= 0) return Offset.Zero + // When scrolled upward + return if (isTabExpandable) { + val prevHeightOffset: Float = scrollOffset + scrollOffset += availableScrollOffset.y + availableScrollOffset.copy(x = 0f, y = scrollOffset - prevHeightOffset) + } else { + Offset.Zero + } + } + + /** + * This function returns the consumed offset. + */ + private fun onPostScrollScreen(availableScrollOffset: Offset): Offset { + if (availableScrollOffset.y < 0f) return Offset.Zero + return if (isTabCollapsing && availableScrollOffset.y > 0) { + // When scrolling downward and overscroll + val prevHeightOffset = scrollOffset + scrollOffset += availableScrollOffset.y + availableScrollOffset.copy(x = 0f, y = scrollOffset - prevHeightOffset) + } else { + Offset.Zero + } + } + + companion object { + val Saver: Saver = listSaver( + save = { listOf(it.scrollOffsetLimit, it.scrollOffset) }, + restore = { + TimetableTabScrollState( + initialOffsetLimit = it[0], + initialScrollOffset = it[1], + ) + }, + ) + } +} + +private val minTabHeight = 32.dp +private val maxTabRowHeight = 84.dp +private val minTabRowHeight = 56.dp diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/TimetableSheet.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/TimetableSheet.kt index 8eaf6fc58..926e18a70 100644 --- a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/TimetableSheet.kt +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/TimetableSheet.kt @@ -1,17 +1,25 @@ package io.github.droidkaigi.confsched2023.sessions.section import androidx.compose.animation.core.animateIntAsState +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import io.github.droidkaigi.confsched2023.model.TimetableItem.Session import io.github.droidkaigi.confsched2023.sessions.component.TimetableScreenScrollState +import io.github.droidkaigi.confsched2023.sessions.component.TimetableTab +import io.github.droidkaigi.confsched2023.sessions.component.TimetableTabRow +import io.github.droidkaigi.confsched2023.sessions.component.rememberTimetableTabScrollState import io.github.droidkaigi.confsched2023.sessions.section.TimetableSheetUiState.Empty import io.github.droidkaigi.confsched2023.sessions.section.TimetableSheetUiState.GridTimetable import io.github.droidkaigi.confsched2023.sessions.section.TimetableSheetUiState.ListTimetable @@ -35,6 +43,7 @@ fun TimetableSheet( onFavoriteClick: (Session) -> Unit, modifier: Modifier = Modifier, ) { + var selectedTabIndex by remember { mutableStateOf(0) } val corner by animateIntAsState( if (timetableScreenScrollState.isScreenLayoutCalculating || timetableScreenScrollState.isSheetExpandable) 40 else 0, label = "Timetable corner state", @@ -43,28 +52,56 @@ fun TimetableSheet( modifier = modifier, shape = RoundedCornerShape(topStart = corner.dp, topEnd = corner.dp), ) { - when (uiState) { - is Empty -> { - Text( - text = "empty", - modifier = Modifier.testTag("empty"), - ) + val tabScrollState = rememberTimetableTabScrollState() + Column( + modifier = Modifier + .fillMaxSize() + .nestedScroll(tabScrollState.nestedScrollConnection), + ) { + TimetableTabRow( + scrollState = tabScrollState, + selectedTabIndex = selectedTabIndex, + ) { + // TODO: Mapping tab data + (0..2).forEach { + TimetableTab( + day = it, + selected = it == selectedTabIndex, + onClick = { + selectedTabIndex = it + }, + scrollState = tabScrollState, + ) + } } + when (uiState) { + is Empty -> { + Text( + text = "empty", + modifier = Modifier.testTag("empty"), + ) + } - is ListTimetable -> { - TimetableList( - uiState = uiState.timetableListUiState, - onContributorsClick = onContributorsClick, - onFavoriteClick = onFavoriteClick, - ) - } + is ListTimetable -> { + TimetableList( + uiState = uiState.timetableListUiState, + onContributorsClick = onContributorsClick, + onFavoriteClick = onFavoriteClick, + modifier = Modifier + .fillMaxSize() + .weight(1f), + ) + } - is GridTimetable -> { - TimetableGrid( - uiState = uiState.timetableGridUiState, - onBookmarked = {}, - modifier = Modifier.fillMaxSize(), - ) + is GridTimetable -> { + TimetableGrid( + uiState = uiState.timetableGridUiState, + onBookmarked = {}, + modifier = Modifier + .fillMaxSize() + .weight(1f), + ) + } } } } From f246d4db016a3193c428a5c11dbbccb5bf631517 Mon Sep 17 00:00:00 2001 From: takahirom Date: Mon, 3 Jul 2023 10:31:38 +0900 Subject: [PATCH 2/2] Separate sheet content state and tab state --- .../sessions/component/TimetableTab.kt | 85 +++++-------------- .../sessions/section/TimetableSheet.kt | 71 ++++++++++++++-- 2 files changed, 85 insertions(+), 71 deletions(-) diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableTab.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableTab.kt index 2b227f40c..8a0130a3b 100644 --- a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableTab.kt +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableTab.kt @@ -24,10 +24,7 @@ import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp @@ -39,7 +36,7 @@ fun TimetableTab( day: Int, selected: Boolean, onClick: () -> Unit, - scrollState: TimetableTabScrollState, + scrollState: TimetableTabState, modifier: Modifier = Modifier, ) { Tab( @@ -62,13 +59,13 @@ fun TimetableTab( style = MaterialTheme.typography.headlineSmall, modifier = Modifier .graphicsLayer { - alpha = (1 - scrollState.progress * 2).coerceAtLeast(0f) + alpha = (1 - scrollState.tabCollapseProgress * 2).coerceAtLeast(0f) } .layout { measurable, constraints -> val placeable = measurable.measure(constraints) layout( placeable.width, - placeable.height - (placeable.height * scrollState.progress).roundToInt(), + placeable.height - (placeable.height * scrollState.tabCollapseProgress).roundToInt(), ) { placeable.placeRelative(0, 0) } @@ -100,7 +97,7 @@ fun TimetableTabIndicator( @Composable fun TimetableTabRow( - scrollState: TimetableTabScrollState, + tabState: TimetableTabState, selectedTabIndex: Int, modifier: Modifier = Modifier, indicator: @Composable (tabPositions: List) -> Unit = @Composable { tabPositions -> @@ -114,7 +111,7 @@ fun TimetableTabRow( ) { TabRow( selectedTabIndex = selectedTabIndex, - modifier = modifier.height(maxTabRowHeight - ((maxTabRowHeight - minTabRowHeight) * scrollState.progress)), + modifier = modifier.height(maxTabRowHeight - ((maxTabRowHeight - minTabRowHeight) * tabState.tabCollapseProgress)), divider = {}, indicator = indicator, tabs = tabs, @@ -122,98 +119,54 @@ fun TimetableTabRow( } @Composable -fun rememberTimetableTabScrollState(): TimetableTabScrollState { +fun rememberTimetableTabState(): TimetableTabState { val offsetLimit = LocalDensity.current.run { (maxTabRowHeight - minTabRowHeight).toPx() } - return rememberSaveable(saver = TimetableTabScrollState.Saver) { - TimetableTabScrollState( + return rememberSaveable(saver = TimetableTabState.Saver) { + TimetableTabState( initialOffsetLimit = -offsetLimit, ) } } @Stable -class TimetableTabScrollState( +class TimetableTabState( initialOffsetLimit: Float = 0f, initialScrollOffset: Float = 0f, ) { - // This value will be like -418.0 + private val scrollOffsetLimit by mutableStateOf(initialOffsetLimit) - /** - * If progress is 0f, the tabs is fully expanded. - * If progress is scrollOffsetLimit, the tabs is fully expanded. - */ - val progress: Float + val tabCollapseProgress: Float get() = scrollOffset / scrollOffsetLimit private val _scrollOffset = mutableStateOf(initialScrollOffset) - private var scrollOffset: Float + var scrollOffset: Float get() = _scrollOffset.value - set(newOffset) { + private set(newOffset) { _scrollOffset.value = newOffset.coerceIn( minimumValue = scrollOffsetLimit, maximumValue = 0f, ) } - private val isTabExpandable: Boolean + val isTabExpandable: Boolean get() = scrollOffset > scrollOffsetLimit - private val isTabCollapsing: Boolean + val isTabCollapsing: Boolean get() = scrollOffset != 0f - val nestedScrollConnection = object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - return onPreScrollScreen(available) - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource, - ): Offset { - return onPostScrollScreen(available) - } - } - - /** - * This function returns the consumed offset. - */ - private fun onPreScrollScreen(availableScrollOffset: Offset): Offset { - if (availableScrollOffset.y >= 0) return Offset.Zero - // When scrolled upward - return if (isTabExpandable) { - val prevHeightOffset: Float = scrollOffset - scrollOffset += availableScrollOffset.y - availableScrollOffset.copy(x = 0f, y = scrollOffset - prevHeightOffset) - } else { - Offset.Zero - } - } - - /** - * This function returns the consumed offset. - */ - private fun onPostScrollScreen(availableScrollOffset: Offset): Offset { - if (availableScrollOffset.y < 0f) return Offset.Zero - return if (isTabCollapsing && availableScrollOffset.y > 0) { - // When scrolling downward and overscroll - val prevHeightOffset = scrollOffset - scrollOffset += availableScrollOffset.y - availableScrollOffset.copy(x = 0f, y = scrollOffset - prevHeightOffset) - } else { - Offset.Zero - } + fun onScroll(y: Float) { + scrollOffset += y } companion object { - val Saver: Saver = listSaver( + val Saver: Saver = listSaver( save = { listOf(it.scrollOffsetLimit, it.scrollOffset) }, restore = { - TimetableTabScrollState( + TimetableTabState( initialOffsetLimit = it[0], initialScrollOffset = it[1], ) diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/TimetableSheet.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/TimetableSheet.kt index 926e18a70..eb11ac027 100644 --- a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/TimetableSheet.kt +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/TimetableSheet.kt @@ -7,11 +7,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable 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.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp @@ -19,7 +23,8 @@ import io.github.droidkaigi.confsched2023.model.TimetableItem.Session import io.github.droidkaigi.confsched2023.sessions.component.TimetableScreenScrollState import io.github.droidkaigi.confsched2023.sessions.component.TimetableTab import io.github.droidkaigi.confsched2023.sessions.component.TimetableTabRow -import io.github.droidkaigi.confsched2023.sessions.component.rememberTimetableTabScrollState +import io.github.droidkaigi.confsched2023.sessions.component.TimetableTabState +import io.github.droidkaigi.confsched2023.sessions.component.rememberTimetableTabState import io.github.droidkaigi.confsched2023.sessions.section.TimetableSheetUiState.Empty import io.github.droidkaigi.confsched2023.sessions.section.TimetableSheetUiState.GridTimetable import io.github.droidkaigi.confsched2023.sessions.section.TimetableSheetUiState.ListTimetable @@ -52,14 +57,14 @@ fun TimetableSheet( modifier = modifier, shape = RoundedCornerShape(topStart = corner.dp, topEnd = corner.dp), ) { - val tabScrollState = rememberTimetableTabScrollState() + val timetableSheetContentScrollState = rememberTimetableSheetContentScrollState() Column( modifier = Modifier .fillMaxSize() - .nestedScroll(tabScrollState.nestedScrollConnection), + .nestedScroll(timetableSheetContentScrollState.nestedScrollConnection), ) { TimetableTabRow( - scrollState = tabScrollState, + tabState = timetableSheetContentScrollState.tabScrollState, selectedTabIndex = selectedTabIndex, ) { // TODO: Mapping tab data @@ -70,7 +75,7 @@ fun TimetableSheet( onClick = { selectedTabIndex = it }, - scrollState = tabScrollState, + scrollState = timetableSheetContentScrollState.tabScrollState, ) } } @@ -106,3 +111,59 @@ fun TimetableSheet( } } } + +@Composable +fun rememberTimetableSheetContentScrollState( + tabScrollState: TimetableTabState = rememberTimetableTabState(), +): TimetableSheetContentScrollState { + return remember { TimetableSheetContentScrollState(tabScrollState) } +} + +@Stable +class TimetableSheetContentScrollState( + val tabScrollState: TimetableTabState, +) { + val nestedScrollConnection = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + return onPreScrollSheetContent(available) + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + return onPostScrollSheetContent(available) + } + } + + /** + * @return consumed offset + */ + private fun onPreScrollSheetContent(availableScrollOffset: Offset): Offset { + if (availableScrollOffset.y >= 0) return Offset.Zero + // When scrolled upward + return if (tabScrollState.isTabExpandable) { + val prevHeightOffset: Float = tabScrollState.scrollOffset + tabScrollState.onScroll(availableScrollOffset.y) + availableScrollOffset.copy(x = 0f, y = tabScrollState.scrollOffset - prevHeightOffset) + } else { + Offset.Zero + } + } + + /** + * @return consumed offset + */ + private fun onPostScrollSheetContent(availableScrollOffset: Offset): Offset { + if (availableScrollOffset.y < 0f) return Offset.Zero + return if (tabScrollState.isTabCollapsing && availableScrollOffset.y > 0) { + // When scrolling downward and overscroll + val prevHeightOffset = tabScrollState.scrollOffset + tabScrollState.onScroll(availableScrollOffset.y) + availableScrollOffset.copy(x = 0f, y = tabScrollState.scrollOffset - prevHeightOffset) + } else { + Offset.Zero + } + } +}