Skip to content

Commit

Permalink
Merge pull request #227 from DroidKaigi/impl_timetable_tab
Browse files Browse the repository at this point in the history
Implemented Timetable Tab
  • Loading branch information
takahirom authored Jul 3, 2023
2 parents 6251dac + 7ea3d29 commit 555680f
Show file tree
Hide file tree
Showing 2 changed files with 297 additions and 19 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
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.graphics.graphicsLayer
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: TimetableTabState,
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.tabCollapseProgress * 2).coerceAtLeast(0f)
}
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(
placeable.width,
placeable.height - (placeable.height * scrollState.tabCollapseProgress).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(
tabState: TimetableTabState,
selectedTabIndex: Int,
modifier: Modifier = Modifier,
indicator: @Composable (tabPositions: List<TabPosition>) -> Unit = @Composable { tabPositions ->
if (selectedTabIndex < tabPositions.size) {
TimetableTabIndicator(
Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]),
)
}
},
tabs: @Composable () -> Unit,
) {
TabRow(
selectedTabIndex = selectedTabIndex,
modifier = modifier.height(maxTabRowHeight - ((maxTabRowHeight - minTabRowHeight) * tabState.tabCollapseProgress)),
divider = {},
indicator = indicator,
tabs = tabs,
)
}

@Composable
fun rememberTimetableTabState(): TimetableTabState {
val offsetLimit = LocalDensity.current.run {
(maxTabRowHeight - minTabRowHeight).toPx()
}
return rememberSaveable(saver = TimetableTabState.Saver) {
TimetableTabState(
initialOffsetLimit = -offsetLimit,
)
}
}

@Stable
class TimetableTabState(
initialOffsetLimit: Float = 0f,
initialScrollOffset: Float = 0f,
) {

private val scrollOffsetLimit by mutableStateOf(initialOffsetLimit)

val tabCollapseProgress: Float
get() = scrollOffset / scrollOffsetLimit

private val _scrollOffset = mutableStateOf(initialScrollOffset)

var scrollOffset: Float
get() = _scrollOffset.value
private set(newOffset) {
_scrollOffset.value = newOffset.coerceIn(
minimumValue = scrollOffsetLimit,
maximumValue = 0f,
)
}

val isTabExpandable: Boolean
get() = scrollOffset > scrollOffsetLimit

val isTabCollapsing: Boolean
get() = scrollOffset != 0f

fun onScroll(y: Float) {
scrollOffset += y
}

companion object {
val Saver: Saver<TimetableTabState, *> = listSaver(
save = { listOf(it.scrollOffsetLimit, it.scrollOffset) },
restore = {
TimetableTabState(
initialOffsetLimit = it[0],
initialScrollOffset = it[1],
)
},
)
}
}

private val minTabHeight = 32.dp
private val maxTabRowHeight = 84.dp
private val minTabRowHeight = 56.dp
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
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.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
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.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
Expand All @@ -35,6 +48,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",
Expand All @@ -43,29 +57,113 @@ fun TimetableSheet(
modifier = modifier,
shape = RoundedCornerShape(topStart = corner.dp, topEnd = corner.dp),
) {
when (uiState) {
is Empty -> {
Text(
text = "empty",
modifier = Modifier.testTag("empty"),
)
val timetableSheetContentScrollState = rememberTimetableSheetContentScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.nestedScroll(timetableSheetContentScrollState.nestedScrollConnection),
) {
TimetableTabRow(
tabState = timetableSheetContentScrollState.tabScrollState,
selectedTabIndex = selectedTabIndex,
) {
// TODO: Mapping tab data
(0..2).forEach {
TimetableTab(
day = it,
selected = it == selectedTabIndex,
onClick = {
selectedTabIndex = it
},
scrollState = timetableSheetContentScrollState.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),
)
}
}
}
}
}

@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
}
}
}

0 comments on commit 555680f

Please sign in to comment.