Skip to content

Instantly share code, notes, and snippets.

@andkulikov
Last active July 27, 2023 08:53
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment