Skip to content

Instantly share code, notes, and snippets.

@andkulikov
Last active July 13, 2024 01:04
Show Gist options
  • Save andkulikov/0f5b7026f601acee698169f860752dbc to your computer and use it in GitHub Desktop.
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/
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)
}
}
}
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)
@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
@AfzalivE
Copy link

AfzalivE commented Jul 13, 2024

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.

    internal val scrollableState = ScrollableState {
        val previous = scrollOffset
        scrollOffset = (scrollOffset - scrollDelta).coerceIn(0f, maxHeight.toFloat())
        val consumed = (previous - scrollOffset)
        consumed
    }

This requires changing scrollOffset to Float I guess.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment