Last active
July 13, 2024 01:04
-
-
Save andkulikov/0f5b7026f601acee698169f860752dbc to your computer and use it in GitHub Desktop.
Code from the "Thinking outside the Box: Custom Compose layouts" Droidcon London 2022 talk https://www.droidcon.com/2022/11/16/thinking-outside-the-box-custom-compose-layouts/
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import androidx.compose.foundation.ExperimentalFoundationApi | |
import androidx.compose.foundation.clipScrollableContainer | |
import androidx.compose.foundation.gestures.Orientation | |
import androidx.compose.foundation.gestures.ScrollableState | |
import androidx.compose.foundation.gestures.scrollable | |
import androidx.compose.foundation.lazy.layout.LazyLayout | |
import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider | |
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.runtime.snapshots.Snapshot | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.layout.AlignmentLine | |
import androidx.compose.ui.layout.Placeable | |
import androidx.compose.ui.unit.Constraints | |
import androidx.compose.ui.unit.Dp | |
import kotlin.math.roundToInt | |
/* | |
* Copyright 2022 The Android Open Source Project | |
* | |
* 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. | |
*/ | |
class LazyTimeGraphState { | |
var scrollOffset by mutableStateOf(0) | |
private set | |
private var maxHeight by mutableStateOf(0) | |
internal fun updateMaxScrollOffset(height: Int) { | |
val currentOffset = Snapshot.withoutReadObservation { scrollOffset } | |
if (currentOffset > height) { | |
scrollOffset = height | |
} | |
maxHeight = height | |
} | |
internal val scrollableState = ScrollableState { | |
val previous = scrollOffset | |
scrollOffset = (scrollOffset - it.toInt()).coerceIn(0, maxHeight) | |
(previous - scrollOffset).toFloat() | |
} | |
} | |
@OptIn(ExperimentalFoundationApi::class) | |
@Composable | |
fun LazyTimeGraph( | |
lines: List<GraphLineInfo>, | |
header: @Composable () -> Unit, | |
label: @Composable (index: Int) -> Unit, | |
bar: @Composable (index: Int) -> Unit, | |
lineHeight: Dp, | |
modifier: Modifier = Modifier, | |
state: LazyTimeGraphState = remember { LazyTimeGraphState() } | |
) { | |
val itemProvider = object : LazyLayoutItemProvider { | |
override val itemCount: Int | |
get() = lines.size * 2 + 1 | |
@Composable | |
override fun Item(index: Int) { | |
if (index == 0) { | |
header() | |
} else { | |
val lineIndex = (index - 1) / 2 | |
if (index % 2 == 1) { | |
label(lineIndex) | |
} else { | |
bar(lineIndex) | |
} | |
} | |
} | |
} | |
LazyLayout( | |
itemProvider, | |
modifier | |
.clipScrollableContainer(Orientation.Vertical) | |
.scrollable(state.scrollableState, Orientation.Vertical) | |
) { constraints -> | |
val headerConstraints = constraints.copy(minWidth = 0, minHeight = 0) | |
val headerPlaceable = measure(0, headerConstraints).single() | |
val startLine = headerPlaceable[GraphBarStartAlignmentLine].let { | |
if (it == AlignmentLine.Unspecified) 0 else it | |
} | |
val endLine = headerPlaceable[GraphBarEndAlignmentLine].let { | |
if (it == AlignmentLine.Unspecified) headerPlaceable.width else it | |
} | |
val labelPlaceables = mutableListOf<Placeable>() | |
val barPlaceables = mutableListOf<Placeable>() | |
val lineHeightPx = lineHeight.roundToPx() | |
val firstLineIndex = state.scrollOffset / lineHeightPx | |
val firstLineOffset = headerPlaceable.height - state.scrollOffset % lineHeightPx | |
var currentOffset = firstLineOffset | |
var currentLineIndex = firstLineIndex | |
val maxBarWidth = endLine - startLine | |
while (currentLineIndex < lines.size && currentOffset < constraints.maxHeight) { | |
// lineIndex * 2 as each line contains 2 slots: label and bar | |
// + 1, as the first slot is reserved for header | |
val labelIndex = currentLineIndex * 2 + 1 | |
labelPlaceables.add( | |
measure( | |
labelIndex, | |
Constraints(maxHeight = lineHeightPx) | |
).single() | |
) | |
val line = lines[currentLineIndex] | |
val barStart = (maxBarWidth * line.startFraction).roundToInt() | |
val barEnd = (maxBarWidth * line.endFraction).roundToInt() | |
// bar is right after label | |
val barIndex = labelIndex + 1 | |
barPlaceables.add( | |
measure( | |
barIndex, | |
Constraints.fixedWidth(barEnd - barStart) | |
).single() | |
) | |
currentLineIndex++ | |
currentOffset += lineHeightPx | |
} | |
val totalLinesHeight = lines.size * lineHeightPx | |
val viewportHeight = constraints.maxHeight - headerPlaceable.height | |
state.updateMaxScrollOffset( | |
maxOf(0, totalLinesHeight - viewportHeight) | |
) | |
layout(headerPlaceable.width, constraints.maxHeight) { | |
var currentY = firstLineOffset | |
barPlaceables.forEachIndexed { localIndex, barPlaceable -> | |
val lineIndex = firstLineIndex + localIndex | |
val barX = (lines[lineIndex].startFraction * maxBarWidth).roundToInt() | |
barPlaceable.place(startLine + barX, currentY) | |
// the label depend on the size of the bar content - so should use the same y | |
val labelPlaceable = labelPlaceables[localIndex] | |
val labelY = currentY + (barPlaceable.height - labelPlaceable.height) / 2 | |
labelPlaceable.place(0, labelY) | |
currentY += barPlaceable.height | |
} | |
headerPlaceable.place(0, 0) | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import androidx.compose.runtime.Composable | |
import androidx.compose.ui.ExperimentalComposeUiApi | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.layout.AlignmentLine | |
import androidx.compose.ui.layout.Layout | |
import androidx.compose.ui.layout.Placeable | |
import androidx.compose.ui.layout.VerticalAlignmentLine | |
import androidx.compose.ui.unit.Constraints | |
import kotlin.math.max | |
import kotlin.math.roundToInt | |
/* | |
* Copyright 2022 The Android Open Source Project | |
* | |
* 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. | |
*/ | |
@OptIn(ExperimentalComposeUiApi::class) | |
@Composable | |
fun TimeGraph( | |
lines: List<GraphLineInfo>, | |
header: @Composable () -> Unit, | |
label: @Composable (index: Int) -> Unit, | |
bar: @Composable (index: Int) -> Unit, | |
modifier: Modifier = Modifier, | |
) { | |
val labels = @Composable { repeat(lines.size) { label(it) } } | |
val bars = @Composable { repeat(lines.size) { bar(it) } } | |
Layout( | |
contents = listOf(header, labels, bars), | |
modifier = modifier | |
) { (headerMeasurables, labelMeasurables, barMeasurables), constraints -> | |
require(headerMeasurables.size == 1) { | |
"header composable should only emit one layout" | |
} | |
val headerPlaceable = headerMeasurables.single().measure( | |
constraints.copy(minWidth = 0, minHeight = 0) | |
) | |
val startLine = headerPlaceable[GraphBarStartAlignmentLine].let { | |
if (it == AlignmentLine.Unspecified) 0 else it | |
} | |
val endLine = headerPlaceable[GraphBarEndAlignmentLine].let { | |
if (it == AlignmentLine.Unspecified) headerPlaceable.width else it | |
} | |
var layoutHeight = headerPlaceable.height | |
require(labelMeasurables.size == lines.size) { | |
"Each of the label composables should always emit one layout." + | |
"Emitted ${labelMeasurables.size}, but expected ${lines.size}" | |
} | |
require(barMeasurables.size == lines.size) { | |
"Each of the bar composables should always emit one layout." + | |
"Emitted ${labelMeasurables.size}, but expected ${lines.size}" | |
} | |
val barPlaceables = mutableListOf<Placeable>() | |
val labelPlaceables = mutableListOf<Placeable>() | |
val maxBarWidth = endLine - startLine | |
lines.forEachIndexed { index, line -> | |
val barStart = (maxBarWidth * line.startFraction).roundToInt() | |
val barEnd = (maxBarWidth * line.endFraction).roundToInt() | |
val barPlaceable = barMeasurables[index].measure( | |
Constraints.fixedWidth(barEnd - barStart) | |
) | |
layoutHeight += barPlaceable.height | |
barPlaceables.add(barPlaceable) | |
labelPlaceables.add( | |
labelMeasurables[index].measure(Constraints(maxHeight = barPlaceable.height)) | |
) | |
} | |
layout(headerPlaceable.width, layoutHeight) { | |
headerPlaceable.place(0, 0) | |
var currentY = headerPlaceable.height | |
lines.forEachIndexed { index, line -> | |
val barX = (lines[index].startFraction * maxBarWidth).roundToInt() | |
val barPlaceable = barPlaceables[index] | |
barPlaceable.place(startLine + barX, currentY) | |
// position the label at the center of bars height: | |
val labelPlaceable = labelPlaceables[index] | |
val labelY = currentY + (barPlaceable.height - labelPlaceable.height) / 2 | |
labelPlaceable.place(0, labelY) | |
currentY += barPlaceable.height | |
} | |
} | |
} | |
} | |
data class GraphLineInfo( | |
val startFraction: Float, | |
val endFraction: Float | |
) { | |
init { | |
require(endFraction >= startFraction) { | |
"barEndFraction($endFraction) should be >= barStartFraction($startFraction)" | |
} | |
require(startFraction in 0f..1f) { | |
"barStartFraction($startFraction) should be within 0f..1f" | |
} | |
require(endFraction in 0f..1f) { | |
"barEndFraction($endFraction) should be within 0f..1f" | |
} | |
} | |
} | |
val GraphBarStartAlignmentLine = VerticalAlignmentLine(::max) | |
val GraphBarEndAlignmentLine = VerticalAlignmentLine(::max) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Composable | |
fun JetLaggedScreen() { | |
Column { | |
var selectedTab by remember { mutableStateOf(SleepTab.Week) } | |
JetlaggedHeaderTabs( | |
onTabSelected = { selectedTab = it }, | |
selectedTab = selectedTab | |
) | |
val earliestStart = sleepGraphData.earliestStart | |
val latestEnd = sleepGraphData.latestEnd | |
val startHour = earliestStart.hour | |
// we round up: for example for 8:24 it should return 9 | |
val endHour = latestEnd.hour + if (latestEnd.minute > 0) 1 else 0 | |
val graphStartTime = LocalTime.of(startHour, 0) | |
val graphEndTime = LocalTime.of(endHour, 0) | |
val lines = sleepGraphData.sleepDayData.map { | |
GraphLineInfo( | |
startFraction = getFractionForTime( | |
graphStartTime, graphEndTime, it.firstSleepStart.toLocalTime() | |
), | |
endFraction = getFractionForTime( | |
graphStartTime, graphEndTime, it.lastSleepEnd.toLocalTime() | |
) | |
) | |
} | |
if (selectedTab == SleepTab.Week) { | |
TimeGraph( | |
modifier = Modifier, | |
lines = lines, | |
header = { Header(startHour, endHour) }, | |
label = { Label(it) }, | |
bar = { Bar(it) } | |
) | |
} else { | |
LazyTimeGraph( | |
modifier = Modifier, | |
lines = lines, | |
header = { Header(startHour, endHour) }, | |
label = { Label(it % lines.size) }, | |
bar = { Bar(it % lines.size) }, | |
lineHeight = 40.dp | |
) | |
} | |
} | |
} | |
@Composable | |
private fun Header(startHour: Int, endHour: Int) { | |
Row( | |
Modifier | |
.background(OffWhite) | |
.padding(bottom = 8.dp) | |
) { | |
Text( | |
text = "Time", | |
style = SmallHeadingStyle, | |
modifier = Modifier.align(Alignment.CenterVertically) | |
) | |
Spacer(modifier = Modifier.width(12.dp)) | |
val brush = remember { | |
Brush.linearGradient(listOf(YellowVariant, Yellow)) | |
} | |
HoursRow( | |
startHour, | |
endHour, | |
Modifier | |
.background(brush) | |
.padding(8.dp), | |
hourStep = 3 | |
) { time -> | |
val timeLabel = time.format(HeaderTimeFormatter).lowercase() | |
Text( | |
text = timeLabel, | |
textAlign = TextAlign.Center, | |
modifier = Modifier.fillMaxWidth() | |
) | |
} | |
} | |
} | |
// Link to the main sample this talk was based on: https://github.com/android/compose-samples/tree/main/JetLagged | |
// Fake data is defined here: https://github.com/android/compose-samples/blob/038c8208307508ceedcb5dd07a4fe2794017644c/JetLagged/app/src/main/java/com/example/jetlagged/FakeSleepData.kt |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
If anyone is using this code and they cannot figure out why flinging doesn't work and scrolling stops abruptly, it's because we must consume scroll delta in
Float
to leave some fractional delta that will be used for the remaining of the fling.This requires changing scrollOffset to
Float
I guess.