generated from JetBrains/compose-multiplatform-template
-
Notifications
You must be signed in to change notification settings - Fork 4
/
TimelineScreen.kt
443 lines (405 loc) · 15.3 KB
/
TimelineScreen.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
package ui.screens.timeline
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.coerceAtLeast
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.times
import models.AllTimelineEvents
import models.ChichenItza
import models.ChristRedeemer
import models.Colosseum
import models.GreatWall
import models.MachuPicchu
import models.Petra
import models.PyramidsGiza
import models.TajMahal
import models.TimelineEvent
import models.Wonder
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import platform.Platform
import ui.composables.AppIconButton
import ui.flattenedImage
import ui.screens.timeline.components.SmallTimeline
import ui.screens.timeline.components.TimelineEventCard
import ui.theme.Raleway
import ui.theme.TenorSans
import ui.theme.accent1
import ui.theme.black
import ui.theme.fgColor
import ui.theme.greyStrong
import ui.theme.white
import ui.utils.filePainterResource
import ui.utils.lerp
import utils.StringUtils.getYrSuffix
import utils.dashedBorder
import wonderouscompose.composeapp.generated.resources.Res
import wonderouscompose.composeapp.generated.resources.circleButtonsSemanticClose
import wonderouscompose.composeapp.generated.resources.icon_prev
import wonderouscompose.composeapp.generated.resources.timelineTitleGlobalTimeline
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimelineScreen(
selectedWonder: Wonder? = null,
onClickBack: () -> Unit,
) = BoxWithConstraints(
Modifier.background(black).windowInsetsPadding(WindowInsets.safeDrawing)
) {
val timelineState = rememberTimelineState()
val verticalPadding = maxHeight / 2
LaunchedEffect(selectedWonder) {
// If a wonder is selected; animate to the start year of that wonder
selectedWonder?.let { timelineState.animateRevealYear(it.startYr) }
}
val currentTimelineEvent = timelineState.currentTimelineEvent
val hapticFeedback = LocalHapticFeedback.current
LaunchedEffect(currentTimelineEvent) {
// Whenever we encounter an event perform a haptic feedback
if (currentTimelineEvent != null) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
Box(
Modifier.fillMaxWidth().pointerInput(Unit) {
detectTransformGestures { _, _, zoom, _ ->
timelineState.setScale(zoom)
}
}.verticalScroll(timelineState.scrollState).padding(vertical = verticalPadding)
.height(timelineState.timelineHeight), contentAlignment = Alignment.Center
) {
Row(Modifier.widthIn(max = 600.dp)) {
Spacer(Modifier.width(16.dp))
TimeMarkers(
yearRange = StartYear..EndYear,
step = 100,
modifier = Modifier.weight(0.4f).fillMaxHeight(),
)
EventMarkers(
yearRange = StartYear..EndYear,
currentYearHighlightRange = timelineState.currentYearHighlightRange,
allEvents = AllTimelineEvents,
modifier = Modifier.weight(0.4f).fillMaxHeight(),
)
TimelineTracksLayout(
modifier = Modifier.weight(4f)
) { wonder ->
WonderTrackWithStickyImage(
wonder = wonder,
isSelected = selectedWonder == wonder,
getCurrentYear = { timelineState.currentYear },
)
}
}
}
CurrentYearLine(
currentYear = timelineState.currentYear,
modifier = Modifier.align(Alignment.Center),
)
// Small bottom timeline
Box(
Modifier.widthIn(max = 800.dp).align(Alignment.BottomCenter).padding(20.dp)
) {
SmallTimeline(
getScrollFraction = { timelineState.scrollFraction },
modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(greyStrong).fillMaxWidth()
.height(72.dp),
highLightedWonder = selectedWonder
)
}
// Events popup
AnimatedContent(
targetState = currentTimelineEvent,
modifier = Modifier.align(Alignment.TopCenter).widthIn(max = 800.dp).padding(top = 80.dp)
.padding(horizontal = 20.dp, vertical = 10.dp),
transitionSpec = { eventPopupTransitionSpec },
contentAlignment = Alignment.Center
) { event ->
if (event == null) {
Spacer(Modifier)
} else {
val description =
if (event.startYearEventWonderTitle == null) stringResource(event.description)
else stringResource(
event.description,
stringResource(event.startYearEventWonderTitle)
)
TimelineEventCard(
year = event.year,
text = description,
darkMode = false,
modifier = Modifier.fillMaxWidth(),
)
}
}
CenterAlignedTopAppBar(
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = black,
titleContentColor = white,
),
title = {
Text(
stringResource(Res.string.timelineTitleGlobalTimeline),
fontSize = 14.sp,
fontFamily = Raleway
)
},
navigationIcon = {
AppIconButton(
icon = Res.drawable.icon_prev,
contentDescription = stringResource(Res.string.circleButtonsSemanticClose),
onClick = onClickBack,
)
},
)
}
@Composable
fun CurrentYearLine(
currentYear: Int,
modifier: Modifier,
) {
val yearSuffix = getYrSuffix(currentYear)
Column(
// Offsetting by half the height to make line at the bottom align with screen center
modifier.requiredHeight(50.dp).offset(y = (-25).dp),
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.Bottom
) {
val textShadow = Shadow(offset = Offset(1f, 1f), blurRadius = 2f)
Row(
Modifier.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically
) {
Text(
"${currentYear.absoluteValue}",
fontSize = 24.sp,
color = white,
fontFamily = TenorSans,
style = TextStyle(shadow = textShadow)
)
Spacer(Modifier.width(8.dp))
Text(
yearSuffix,
fontSize = 16.sp,
color = white,
fontFamily = TenorSans,
style = TextStyle(shadow = textShadow)
)
}
Spacer(
Modifier.fillMaxWidth().height(1.dp).dashedBorder(
BorderStroke(Dp.Hairline, Color.White), on = 2.dp, off = 4.dp
)
)
}
}
@Composable
fun TimelineTracksLayout(
modifier: Modifier = Modifier,
trackItemContent: @Composable (wonder: Wonder) -> Unit,
) {
Row(
modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceEvenly
) {
Spacer(Modifier.weight(.2f))
BoxWithConstraints(Modifier.weight(1f).fillMaxHeight()) {
TrackItem(wonder = PyramidsGiza, content = trackItemContent)
TrackItem(wonder = GreatWall, content = trackItemContent)
TrackItem(wonder = ChristRedeemer, content = trackItemContent)
}
Spacer(Modifier.weight(.2f))
BoxWithConstraints(Modifier.weight(1f).fillMaxHeight()) {
TrackItem(wonder = Petra, content = trackItemContent)
TrackItem(wonder = MachuPicchu, content = trackItemContent)
}
Spacer(Modifier.weight(.2f))
BoxWithConstraints(Modifier.weight(1f).fillMaxHeight()) {
TrackItem(wonder = Colosseum, content = trackItemContent)
TrackItem(wonder = ChichenItza, content = trackItemContent)
TrackItem(wonder = TajMahal, content = trackItemContent)
}
Spacer(Modifier.weight(.2f))
}
}
@Composable
inline fun BoxWithConstraintsScope.TrackItem(
wonder: Wonder,
content: @Composable (wonder: Wonder) -> Unit,
) {
val start = (wonder.startYr.toFloat() - StartYear) / TimelineDuration * maxHeight
val end = (wonder.endYr.toFloat() - StartYear) / TimelineDuration * maxHeight
val minRange = maxWidth * 1.5f // should be at least as high as it is wide
val range = end - start
// For some wonders their range is too small to display correctly so we define safeRange
val safeRange = maxOf(range, minRange)
Box(
Modifier.fillMaxWidth().height(safeRange).offset(0.dp, start),
) {
content(wonder)
}
}
@Composable
fun WonderTrackWithStickyImage(
wonder: Wonder,
isSelected: Boolean,
getCurrentYear: () -> Int,
) {
BoxWithConstraints(
Modifier.fillMaxSize().background(
color = wonder.fgColor,
shape = RoundedCornerShape(percent = 100),
).padding(6.dp)
) {
val startYear = wonder.startYr
val yearRange = wonder.endYr - wonder.startYr
val offsetEnd = (maxHeight - ThumbSize).coerceAtLeast(minimumValue = 0.dp)
Image(
painter = filePainterResource(wonder.flattenedImage),
modifier = Modifier.fillMaxWidth().height(ThumbSize)
.offset {
val curFraction =
((getCurrentYear().toFloat() - startYear) / yearRange).coerceIn(0f, 1f)
val vertOffset = lerp(0, offsetEnd.toPx().roundToInt(), curFraction)
IntOffset(0, vertOffset)
}.clip(CircleShape),
contentDescription = stringResource(wonder.title),
contentScale = ContentScale.Crop,
colorFilter = if (isSelected) null else ColorFilter.tint(
color = wonder.fgColor, blendMode = blendMode
),
)
}
}
@Composable
private fun TimeMarkers(
yearRange: IntRange, step: Int, modifier: Modifier = Modifier,
) {
val years = remember(yearRange) { yearRange.step(step) }
LazyColumn(
modifier, verticalArrangement = Arrangement.SpaceBetween
) {
items(years.count()) {
Text(
"${years.elementAt(it).absoluteValue}", color = white, fontSize = 13.sp
)
}
}
}
@Composable
private fun EventMarkers(
yearRange: IntRange,
currentYearHighlightRange: IntRange,
allEvents: List<TimelineEvent>,
modifier: Modifier = Modifier,
) {
Box(modifier) {
allEvents.map {
val highlighted = it.year in currentYearHighlightRange
val verticalBias = lerp(
start = -1f,
stop = 1f,
fraction = (it.year.toFloat() - yearRange.first) / (yearRange.last - yearRange.first).toFloat()
)
key(it) {
EventMarker(
highLighted = highlighted,
modifier = Modifier.align(BiasAlignment(0f, verticalBias))
)
}
}
}
}
@Composable
private fun EventMarker(
highLighted: Boolean,
modifier: Modifier,
) {
val size by animateDpAsState(if (highLighted) 8.dp else 3.dp)
val blurRadius by animateDpAsState(if (highLighted) 10.dp else 1.dp)
Box(
modifier.offset(y = (-4).dp).size(size).background(accent1, CircleShape)
.blur(blurRadius, edgeTreatment = BlurredEdgeTreatment.Unbounded)
.background(accent1, CircleShape)
)
}
/**
* Color blend mode is not supported on Android versions below 29
*/
val blendMode: BlendMode
get() {
val platform = platform.platform
return if (platform is Platform.Android && platform.version < 29) BlendMode.Overlay else BlendMode.Color
}
private val AnimatedContentTransitionScope<TimelineEvent?>.eventPopupTransitionSpec
get() = slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Down,
animationSpec = tween(durationMillis = 500, delayMillis = 500)
) + fadeIn(tween(delayMillis = 500)) togetherWith slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Up,
animationSpec = tween(delayMillis = 2000)
) + fadeOut()
val ThumbSize = 200.dp
@Preview
@Composable
fun TimelineScreenPreview() {
TimelineScreen(selectedWonder = TajMahal, onClickBack = {})
}