diff --git a/README.md b/README.md index 184bf843..60f1557c 100644 --- a/README.md +++ b/README.md @@ -95,22 +95,22 @@ previous searches using a database, domain with ViewModel. ### State -| Tutorial | Preview | -|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------| -|

4-1-1 Remember&MutableState


Remember and mutableState effect recomposition and states.

• remember
• State
• Recomposition
| | -| | | -|

4-2-3 Scoped Recomposition


How hierarchy of Composables effects Smart Composition.

• remember
• Recomposition
• State
| | -| | | -|

4-4 Custom Remember


Create a custom remember and custom component to have badge that changes its shape based on properties set by custom rememberable.

• remember
• State
• Recomposition
• Custom Layout
| | -| | | -|

4-5-1 SideEffect1


Use remember functions like rememberCoroutineScope, and rememberUpdatedState and side-effect functions such as LaunchedEffect and DisposableEffect.

• remember
• rememberCoroutineScope
• rememberUpdatedState
• LaunchedEffect
• DisposableEffect
| | -| | | -|

4-5-2 SideEffect2


Use SideEffect, derivedStateOf, produceState and snapshotFlow.

• remember
• SideEffect
• derivedStateOf
• produceStateOf
• snapshotFlow
| | -| | | -|

4-7-3 Compose Phases3


How deferring a state read changes which phases of frame(Composition, Layout, Draw) are called.

• Modifier
• Recomposition
• Composition
• Layout
• Draw
| | -| | | -|

4-11-6 LazyList Scroll Direction


Detect scroll direction of a LazyColumn using LazyListStated.

• Modifier
• LazyColumn
• LazyListState
• derivedStateOf
• snapshotFlow
| | -| | | +| Tutorial | Preview | +|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------| +|

4-1-1 Remember&MutableState


Remember and mutableState effect recomposition and states.

• remember
• State
• Recomposition
| | +| | | +|

4-2-3 Scoped Recomposition


How hierarchy of Composables effects Smart Composition.

• remember
• Recomposition
• State
| | +| | | +|

4-4 Custom Remember


Create a custom remember and custom component to have badge that changes its shape based on properties set by custom rememberable.

• remember
• State
• Recomposition
• Custom Layout
| | +| | | +|

4-5-1 SideEffect1


Use remember functions like rememberCoroutineScope, and rememberUpdatedState and side-effect functions such as LaunchedEffect and DisposableEffect.

• remember
• rememberCoroutineScope
• rememberUpdatedState
• LaunchedEffect
• DisposableEffect
| | +| | | +|

4-5-2 SideEffect2


Use SideEffect, derivedStateOf, produceState and snapshotFlow.

• remember
• SideEffect
• derivedStateOf
• produceStateOf
• snapshotFlow
| | +| | | +|

4-7-3 Compose Phases3


How deferring a state read changes which phases of frame(Composition, Layout, Draw) are called.

• Modifier
• Recomposition
• Composition
• Layout
• Draw
| | +| | | +|

4-12 LazyList Scroll Direction


Detect scroll direction of a LazyColumn using LazyListStated.

• Modifier
• LazyColumn
• LazyListState
• derivedStateOf
• snapshotFlow
| | +| | | | | ### Gesture @@ -414,3 +414,6 @@ And run task to check for compiler reports for stability inside **build/compiler
[Composable metrics-Chris Banes](https://chrisbanes.me/posts/composable-metrics/)
+[Creating a particle explosion animation in Jetpack Compose-Omkar Tenkale](https://proandroiddev.com/creating-a-particle-explosion-animation-in-jetpack-compose-4ee42022bbfa) +
+ diff --git a/Tutorial1-1Basics/build.gradle.kts b/Tutorial1-1Basics/build.gradle.kts index 5f93c95f..871806d5 100644 --- a/Tutorial1-1Basics/build.gradle.kts +++ b/Tutorial1-1Basics/build.gradle.kts @@ -70,7 +70,7 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.androidx.appcompat) - implementation(libs.material.v1120) + implementation(libs.androidx.compose.material) implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter2_material_widgets/LazyColumnItemSwapAnimation.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter2_material_widgets/LazyColumnItemSwapAnimation.kt index ac77bc81..f868789a 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter2_material_widgets/LazyColumnItemSwapAnimation.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter2_material_widgets/LazyColumnItemSwapAnimation.kt @@ -4,7 +4,6 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.animateScrollBy @@ -29,6 +28,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -58,7 +58,6 @@ private class MyData(val uuid: String, val value: String) // TODO Fix increasing swap animations @Preview -@OptIn(ExperimentalFoundationApi::class) @Composable private fun AnimatedList() { Column(modifier = Modifier.fillMaxSize()) { @@ -92,7 +91,7 @@ private fun AnimatedList() { Row( modifier = Modifier - .animateItemPlacement( + .animateItem( tween(durationMillis = duration) ) .shadow(1.dp, RoundedCornerShape(8.dp)) @@ -139,7 +138,7 @@ private fun AnimatedList() { 0 } - animatedSwap( + AnimatedSwap( lazyListState = lazyListState, items = items, from = from, @@ -189,7 +188,7 @@ private fun alternativeAnimate( coroutineScope: CoroutineScope, lazyListState: LazyListState, animatable: Animatable, - items: SnapshotStateList + items: SnapshotStateList, ) { val difference = from - to @@ -228,15 +227,20 @@ private fun alternativeAnimate( } @Composable -private fun animatedSwap( +private fun AnimatedSwap( lazyListState: LazyListState, items: SnapshotStateList, from: Int, to: Int, duration: Int, - onFinish: () -> Unit + onFinish: () -> Unit, ) { + val visibleItems by remember { + derivedStateOf { + lazyListState.layoutInfo.visibleItemsInfo + } + } LaunchedEffect(key1 = Unit) { @@ -245,9 +249,6 @@ private fun animatedSwap( var currentValue: Int = from - var visibleItems = - lazyListState.layoutInfo.visibleItemsInfo - var visibleItemIndices = visibleItems.map { it.index } // If current item is not in viewPort animate to it first before starting scrolling @@ -262,8 +263,6 @@ private fun animatedSwap( } repeat(abs(difference)) { - - val temp = currentValue if (increasing) { @@ -272,9 +271,6 @@ private fun animatedSwap( currentValue-- } - visibleItems = - lazyListState.layoutInfo.visibleItemsInfo - visibleItemIndices = visibleItems.map { it.index } diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter2_material_widgets/Tutorial2_17_1PullToRefresh1.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter2_material_widgets/Tutorial2_17_1PullToRefresh1.kt new file mode 100644 index 00000000..a14d6349 --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter2_material_widgets/Tutorial2_17_1PullToRefresh1.kt @@ -0,0 +1,113 @@ +@file:OptIn(ExperimentalMaterialApi::class) + +package com.smarttoolfactory.tutorial1_1basics.chapter2_material_widgets + +import android.widget.Toast +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.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay + +@Preview +@Composable +fun Tutorial2_17Screen1() { + TutorialContent() +} + +@Composable +private fun TutorialContent() { + var isRefreshing by remember { + mutableStateOf(false) + } + + val context = LocalContext.current + + LaunchedEffect(isRefreshing) { + if (isRefreshing) { + delay(1000) + isRefreshing = false + } + } + + val pullToRefreshState = rememberPullRefreshState( + refreshing = isRefreshing, + onRefresh = { + isRefreshing = true + Toast.makeText(context, "onRefresh called", Toast.LENGTH_SHORT).show() + }, + refreshingOffset = 40.dp, + refreshThreshold = 40.dp + ) + + Box(modifier = Modifier.fillMaxSize()) { + + Column( + modifier = Modifier.pullRefresh(state = pullToRefreshState) + ) { + + Button( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + onClick = { + isRefreshing = isRefreshing.not() + } + ) { + Text("Refreshing: $isRefreshing") + } + + LazyColumn( + modifier = Modifier + .fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(100) { + Text( + modifier = Modifier.fillMaxWidth() + .shadow(4.dp, RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(16.dp), + text = "Row: $it", + fontSize = 18.sp + ) + } + } + } + + PullRefreshIndicator( + modifier = Modifier + .align(Alignment.TopCenter) + // 🔥To display actual position of Composable, drawn into Canvas and translated in GraphicLayer +// .border(2.dp, Color.Red) + , + refreshing = isRefreshing, + state = pullToRefreshState + ) + } +} diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter2_material_widgets/Tutorial2_17_2CustomPullToRefresh2.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter2_material_widgets/Tutorial2_17_2CustomPullToRefresh2.kt new file mode 100644 index 00000000..d7ce08fa --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter2_material_widgets/Tutorial2_17_2CustomPullToRefresh2.kt @@ -0,0 +1,159 @@ +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) + +package com.smarttoolfactory.tutorial1_1basics.chapter2_material_widgets + +import android.widget.Toast +import androidx.compose.animation.core.animateFloatAsState +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.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay + +@Preview +@Composable +fun Tutorial2_17Screen2() { + TutorialContent() +} + +@Composable +private fun TutorialContent() { + var isRefreshing by remember { + mutableStateOf(false) + } + + val context = LocalContext.current + val refreshThreshold = 60.dp + + val pullToRefreshState = rememberPullRefreshState( + refreshing = isRefreshing, + onRefresh = { + isRefreshing = true + Toast.makeText(context, "onRefresh called", Toast.LENGTH_SHORT).show() + }, + refreshThreshold = refreshThreshold, + refreshingOffset = 50.dp + ) + + LaunchedEffect(isRefreshing) { + if (isRefreshing) { + delay(1500) + isRefreshing = false + } + } + + val density = LocalDensity.current + val refreshThresholdPx = with(density) { refreshThreshold.toPx() } + + val offset by remember { + derivedStateOf { + if (isRefreshing) { + refreshThresholdPx + } else if ( + pullToRefreshState.progress <= 1f + ) { + (refreshThresholdPx * pullToRefreshState.progress).coerceAtLeast(0f) + + } else { + refreshThresholdPx + (refreshThresholdPx * (pullToRefreshState.progress - 1) * .2f).coerceAtLeast(0f) + } + } + } + + val animatedOffset by animateFloatAsState( + targetValue = offset, + label = "pull to refresh" + ) + + Box(modifier = Modifier.fillMaxSize()) { + + Column( + modifier = Modifier + .pullRefresh(pullToRefreshState) + ) { + + LazyColumn( + modifier = Modifier + .graphicsLayer { + translationY = animatedOffset + } + .weight(1f), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(100) { + Text( + modifier = Modifier.fillMaxWidth() + .shadow(4.dp, RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(16.dp), + text = "Row: $it", + fontSize = 18.sp + ) + } + } + + Text("Position: $offset, progress: ${pullToRefreshState.progress}") + + Button( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + onClick = { + isRefreshing = isRefreshing.not() + } + ) { + Text("Refreshing: $isRefreshing") + } + } + + Box( + modifier = Modifier + .align(Alignment.TopCenter) + // 🔥 default Modifier for transform and scale +// .pullRefreshIndicatorTransform(state = pullToRefreshState, scale = true) + // 🔥 custom Modifier for transform and scale + .graphicsLayer { + translationY = (animatedOffset - refreshThresholdPx / 2) + .coerceAtMost(refreshThresholdPx * .25f) + scaleX = if (isRefreshing) 1f else pullToRefreshState.progress.coerceIn(0f, 1f) + scaleY = if (isRefreshing) 1f else pullToRefreshState.progress.coerceIn(0f, 1f) + } + .shadow(2.dp, CircleShape) + .background(Color.White, CircleShape) + .padding(4.dp) + + ) { + CircularProgressIndicator() + } + } +} \ No newline at end of file diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/Tutorial3_12LazyListLayoutComposeOffscreen.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/Tutorial3_12LazyListLayoutComposeOffscreen.kt new file mode 100644 index 00000000..17ce9955 --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/Tutorial3_12LazyListLayoutComposeOffscreen.kt @@ -0,0 +1,207 @@ +package com.smarttoolfactory.tutorial1_1basics.chapter3_layout + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.layout +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay + +@Preview +@Composable +fun LazyCompositionCount() { + BoxWithConstraints { + val itemWidth = maxWidth / 2 - 40.dp + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + + Text("Default Behavior") + + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), + ) { + items(30) { + var loading by remember { + mutableStateOf(true) + } + + LaunchedEffect(Unit) { + println("Composing First LazyRow item: $it") + delay(1000) + loading = false + } + MyRow(itemWidth, loading, it) + } + } + + Text("Compose 4 items offscreen in right direction") + + LazyRow( + modifier = Modifier.fillMaxWidth().layout { measurable, constraints -> + val width = constraints.maxWidth + 4 * itemWidth.roundToPx() + val wrappedConstraints = constraints.copy(minWidth = width, maxWidth = width) + + val placeable = measurable.measure(wrappedConstraints) + + layout( + placeable.width, placeable.height + ) { + val xPos = (placeable.width - constraints.maxWidth) / 2 + placeable.placeRelative(xPos, 0) + } + }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp) + ) { + items(30) { + var loading by remember { + mutableStateOf(true) + } + + LaunchedEffect(Unit) { + println("Composing Second LazyRow item: $it") + delay(1000) + loading = false + } + MyRow(itemWidth, loading, it) + } + } + + Text("Compose4 items offscreen in right and limit fling") + + LazyRow( + modifier = Modifier + .nestedScroll(rememberFlingNestedScrollConnection()) + .fillMaxWidth() + .layout { measurable, constraints -> + val width = constraints.maxWidth + 4 * itemWidth.roundToPx() + val wrappedConstraints = constraints.copy(minWidth = width, maxWidth = width) + + val placeable = measurable.measure(wrappedConstraints) + + layout( + placeable.width, placeable.height + ) { + val xPos = (placeable.width - constraints.maxWidth) / 2 + placeable.placeRelative(xPos, 0) + } + }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp) + ) { + items(30) { + var loading by remember { + mutableStateOf(true) + } + + LaunchedEffect(Unit) { + println("Composing Third LazyRow item: $it") + delay(1000) + loading = false + } + MyRow(itemWidth, loading, it) + } + } + + Text("Compose 4 items offscreen in both scroll directions") + + LazyRow( + modifier = Modifier + .nestedScroll(rememberFlingNestedScrollConnection()) + .fillMaxWidth() + .layout { measurable, constraints -> + val width = constraints.maxWidth + 8 * itemWidth.roundToPx() + val wrappedConstraints = constraints.copy(minWidth = width, maxWidth = width) + + val placeable = measurable.measure(wrappedConstraints) + + layout( + placeable.width, placeable.height + ) { + placeable.placeRelative(0, 0) + } + }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues( + vertical = 16.dp, + horizontal = 16.dp + itemWidth * 4 + ) + ) { + items(30) { + var loading by remember { + mutableStateOf(true) + } + + LaunchedEffect(Unit) { + println("Composing Forth LazyRow item: $it") + delay(1000) + loading = false + } + MyRow(itemWidth, loading, it) + } + } + + } + } +} + +@Composable +private fun MyRow(itemWidth: Dp, loading: Boolean, it: Int) { + Box( + modifier = Modifier + .size(itemWidth, 100.dp) + .background(if (loading) Color.Red else Color.Green, RoundedCornerShape(16.dp)) + .padding(16.dp) + ) { + Text("Row $it", fontSize = 26.sp, color = Color.White) + } +} + +@Composable +fun rememberFlingNestedScrollConnection() = remember { + object : NestedScrollConnection { + + override suspend fun onPreFling(available: Velocity): Velocity { + val threshold = 3000f + val availableX = available.x + val consumed = if (availableX> threshold) { + availableX - threshold + } else if (availableX < -threshold) { + availableX + threshold + } else { + 0f + } + return Velocity(consumed, 0f) + } + + } +} \ No newline at end of file diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/Tutorial3_2_1CustomLayout1.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/Tutorial3_2_1CustomLayout1.kt index 8d80056e..bec52330 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/Tutorial3_2_1CustomLayout1.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/Tutorial3_2_1CustomLayout1.kt @@ -88,7 +88,7 @@ private fun TutorialContent() { @Composable private fun CustomColumn( modifier: Modifier, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { Layout( modifier = modifier, @@ -156,13 +156,13 @@ private fun Chip(modifier: Modifier = Modifier, text: String) { } /** - * This layout is a staggered grid which aligns the chip in next row based on maximumh + * This layout is a staggered grid which aligns the chip in next row based on maximum * height of Chip on previous row */ @Composable fun ChipStaggeredGrid( modifier: Modifier = Modifier, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { Layout( @@ -190,7 +190,13 @@ fun ChipStaggeredGrid( // Measure each child val placeable = - measurable.measure(constraints.copy(minWidth = 0, maxWidth = Constraints.Infinity)) + measurable.measure( + constraints.copy( + minWidth = 0, + minHeight = 0, +// maxWidth = Constraints.Infinity + ) + ) val placeableWidth = placeable.width val placeableHeight = placeable.height diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/Tutorial3_6_2SubComposeAndFlexLayout.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/Tutorial3_6_2SubComposeAndFlexLayout.kt index 787d6fee..10fc42b6 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/Tutorial3_6_2SubComposeAndFlexLayout.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/Tutorial3_6_2SubComposeAndFlexLayout.kt @@ -1,6 +1,7 @@ package com.smarttoolfactory.tutorial1_1basics.chapter3_layout -import android.widget.Toast +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -12,11 +13,18 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -66,9 +74,38 @@ private fun TutorialContent() { val messages = remember { mutableStateListOf() } val sdf = remember { SimpleDateFormat("hh:mm", Locale.ROOT) } + var selected by remember { + mutableStateOf(false) + } + + val saturation by animateFloatAsState( + targetValue = if (selected) 0f else 1f, + animationSpec = tween(3000) + ) + Column( modifier = Modifier .fillMaxSize() + .then( + Modifier.drawWithCache { + val graphicsLayer = obtainGraphicsLayer() + + graphicsLayer.apply { + record { + drawContent() + } + colorFilter = ColorFilter.colorMatrix( + ColorMatrix().apply { + setToSaturation(saturation) + }) + } + + onDrawWithContent { + drawLayer(graphicsLayer) + } + } + + ) .background(Color(0xffFBE9E7)) ) { @@ -76,7 +113,7 @@ private fun TutorialContent() { title = "Flexible ChatRows", description = description, onClick = { - Toast.makeText(context, description, Toast.LENGTH_SHORT).show() + selected = selected.not() } ) val scrollState = rememberLazyListState() @@ -92,12 +129,13 @@ private fun TutorialContent() { items(messages) { message: ChatMessage -> // Remember random stats icon to not create in every recomposition - val messageStatus = remember { MessageStatus.values()[Random.nextInt(3)] } + val messageStatus = remember { MessageStatus.entries[Random.nextInt(3)] } // Toggle between sent and received message when (message.id.toInt() % 4) { 1 -> { SentMessageRowAlt( + modifier = Modifier, text = message.message, quotedMessage = "Quote message", messageTime = sdf.format(System.currentTimeMillis()), @@ -105,16 +143,20 @@ private fun TutorialContent() { ) } + 2 -> { ReceivedMessageRowAlt( + modifier = Modifier, text = message.message, quotedMessage = "Quote", messageTime = sdf.format(System.currentTimeMillis()), ) } + 3 -> { SentMessageRowAlt( + modifier = Modifier, text = message.message, quotedImage = R.drawable.landscape1, messageTime = sdf.format(System.currentTimeMillis()), @@ -122,8 +164,10 @@ private fun TutorialContent() { ) } + else -> { ReceivedMessageRowAlt( + modifier = Modifier, text = message.message, quotedImage = R.drawable.landscape2, messageTime = sdf.format(System.currentTimeMillis()), @@ -134,7 +178,7 @@ private fun TutorialContent() { } ChatInput( - modifier=Modifier.imePadding(), + modifier = Modifier.imePadding(), onMessageChange = { messageContent -> messages.add( ChatMessage( diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/chat/ReceivedMessageRow.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/chat/ReceivedMessageRow.kt index 88b134c2..e0316c39 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/chat/ReceivedMessageRow.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/chat/ReceivedMessageRow.kt @@ -30,6 +30,7 @@ var recipientOriginalName = "Some user" */ @Composable fun ReceivedMessageRowAlt( + modifier: Modifier = Modifier, text: String, quotedMessage: String? = null, quotedImage: Int? = null, @@ -47,11 +48,10 @@ fun ReceivedMessageRowAlt( // This is chat bubble SubcomposeColumn( - modifier = Modifier + modifier = modifier .shadow(1.dp, RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp)) - .background(Color.White) - .clickable { }, + .background(Color.White), content = { RecipientName( name = recipientRegisteredName, diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/chat/SentMessageRow.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/chat/SentMessageRow.kt index e3cc298c..9375c4a9 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/chat/SentMessageRow.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/chat/SentMessageRow.kt @@ -21,6 +21,7 @@ import com.smarttoolfactory.tutorial1_1basics.ui.SentQuoteColor */ @Composable fun SentMessageRowAlt( + modifier: Modifier = Modifier, text: String, quotedMessage: String? = null, quotedImage: Int? = null, @@ -40,15 +41,13 @@ fun SentMessageRowAlt( ) { - - + // This is chat bubble SubcomposeColumn( - modifier = Modifier + modifier = modifier .shadow(1.dp, RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp)) - .background(SentMessageColor) - .clickable { }, + .background(SentMessageColor), content = { // 💬 Quoted message diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_11_4LazyListRecomposition4.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_11_4LazyListRecomposition4.kt index 28d4cd65..ebfebd4e 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_11_4LazyListRecomposition4.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_11_4LazyListRecomposition4.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.ViewModel import com.smarttoolfactory.tutorial1_1basics.ui.backgroundColor +import com.smarttoolfactory.tutorial1_1basics.ui.components.StyleableTutorialText import com.smarttoolfactory.tutorial1_1basics.ui.components.TutorialHeader import com.smarttoolfactory.tutorial1_1basics.ui.components.getRandomColor import java.util.UUID @@ -43,6 +44,8 @@ import java.util.UUID @Preview @Composable fun Tutorial4_11Screen4() { + // Adding or removing item to bottom of the list only recomposes item added + // no items when last item is removed TutorialContent() } @@ -56,6 +59,12 @@ private fun TutorialContent() { ) { TutorialHeader(text = "LazyList Recomposition4") + StyleableTutorialText( + text = "In this example items are added to bottom which only triggers composition for added item. When last " + + "item is removed nothing is recomposed", + bullets = false + ) + val viewModel = AddRemoveViewModel() MainScreen(viewModel = viewModel) } @@ -63,7 +72,7 @@ private fun TutorialContent() { @Composable private fun MainScreen( - viewModel: AddRemoveViewModel + viewModel: AddRemoveViewModel, ) { @@ -88,7 +97,7 @@ private fun MainScreen( viewModel.addTask(Task(id = UUID.randomUUID().toString(), title = "Task: $index")) } ) { - Text("Add Task") + Text("Add Task to Bottom") } Spacer(modifier = Modifier.height(10.dp)) @@ -101,7 +110,7 @@ private fun MainScreen( } } ) { - Text("Delete Task") + Text("Delete Last Task") } Spacer(modifier = Modifier.height(10.dp)) @@ -119,7 +128,7 @@ private fun ListScreen( // prevents recomposition for ListScreen scope even if ListItems are // already prevented recomposition with ViewModel lambda stabilization tasks: SnapshotStateList, - onItemClick: (Int) -> Unit + onItemClick: (Int) -> Unit, ) { SideEffect { @@ -161,7 +170,7 @@ private fun ListScreen( private fun TaskListItem(item: Task, onItemClick: () -> Unit) { SideEffect { - println("Recomposing ${item.id}, selected: ${item.isSelected}") + println("Recomposing ${item.title}, selected: ${item.isSelected}") } Column( @@ -178,7 +187,7 @@ private fun TaskListItem(item: Task, onItemClick: () -> Unit) { } .padding(8.dp) ) { - Text(item.title, fontSize = 20.sp) + Text("${item.title}, id: ${item.id.substring(startIndex = item.id.length - 6)}", fontSize = 20.sp) if (item.isSelected) { Icon( modifier = Modifier @@ -196,7 +205,7 @@ private fun TaskListItem(item: Task, onItemClick: () -> Unit) { internal class AddRemoveViewModel : ViewModel() { - private val initialList = List(5) { index: Int -> + private val initialList = List(6) { index: Int -> Task(id = UUID.randomUUID().toString(), title = "Task: $index") } diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_11_5LazyListRecomposition5.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_11_5LazyListRecomposition5.kt index 336fa492..e682c753 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_11_5LazyListRecomposition5.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_11_5LazyListRecomposition5.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.ViewModel import com.smarttoolfactory.tutorial1_1basics.ui.backgroundColor +import com.smarttoolfactory.tutorial1_1basics.ui.components.StyleableTutorialText import com.smarttoolfactory.tutorial1_1basics.ui.components.TutorialHeader import com.smarttoolfactory.tutorial1_1basics.ui.components.getRandomColor import java.util.UUID @@ -44,6 +45,8 @@ import java.util.UUID @Preview @Composable fun Tutorial4_11Screen5() { + // 🔥 Adding item to the top recomposes every item as removing first item + // 🔥 Using key keeps scroll position when items added or removed before visible item TutorialContent() } @@ -57,6 +60,13 @@ private fun TutorialContent() { ) { TutorialHeader(text = "LazyList Recomposition5") + StyleableTutorialText( + text = "In this example items are added to top which recomposes every item. When first item is " + + "removed remaining items get recomposed. Long clicking an item removes it from the list and " + + "recomposes items below deleted item.", + bullets = false + ) + val viewModel = EditViewModel() MainScreen(viewModel = viewModel) } @@ -64,7 +74,7 @@ private fun TutorialContent() { @Composable private fun MainScreen( - viewModel: EditViewModel + viewModel: EditViewModel, ) { @@ -86,7 +96,7 @@ private fun MainScreen( modifier = Modifier.padding(8.dp), ) { - val tasks = viewModel.people + val tasks = viewModel.taskList Button( modifier = Modifier.fillMaxWidth(), @@ -100,7 +110,7 @@ private fun MainScreen( ) } ) { - Text("Add Task") + Text("Add Task to Top") } Spacer(modifier = Modifier.height(10.dp)) @@ -113,7 +123,7 @@ private fun MainScreen( } } ) { - Text("Delete Task") + Text("Delete Task from Top") } Spacer(modifier = Modifier.height(10.dp)) @@ -134,7 +144,7 @@ private fun ListScreen( // already prevented recomposition with ViewModel lambda stabilization tasks: SnapshotStateList, onItemClick: (Int) -> Unit, - onItemLongClick: (Task) -> Unit + onItemLongClick: (Task) -> Unit, ) { SideEffect { @@ -157,7 +167,9 @@ private fun ListScreen( ) { itemsIndexed( items = tasks, - key = { index: Int, task: Task -> + // 🔥 Using key keeps scroll position when items added or + // removed before visible item + key = { _: Int, task: Task -> task.hashCode() } ) { index, task -> @@ -178,11 +190,11 @@ private fun ListScreen( private fun TaskListItem( item: Task, onItemClick: () -> Unit, - onItemLongClick: (Task) -> Unit + onItemLongClick: (Task) -> Unit, ) { SideEffect { - println("Recomposing ${item.id}, selected: ${item.isSelected}") + println("Recomposing ${item.title}, selected: ${item.isSelected}") } Column( @@ -204,7 +216,7 @@ private fun TaskListItem( ) .padding(8.dp) ) { - Text(item.title, fontSize = 20.sp) + Text("${item.title}, id: ${item.id.substring(startIndex = item.id.length - 6)}", fontSize = 20.sp) if (item.isSelected) { Icon( @@ -220,35 +232,37 @@ private fun TaskListItem( } } - private class EditViewModel : ViewModel() { - private val initialList = List(5) { index: Int -> - val id = UUID.randomUUID().toString().take(12) - Task(id = id, title = "Task: $id") + private val initialList = List(6) { index: Int -> + val id = UUID.randomUUID().toString().apply { + substring( + startIndex = lastIndex - 6 + ) + } + Task(id = id, title = "Task: $index") } - val people = mutableStateListOf().apply { + val taskList = mutableStateListOf().apply { addAll(initialList) } fun toggleSelection(index: Int) { println("toggle index: $index") - val item = people[index] + val item = taskList[index] val isSelected = item.isSelected - people[index] = item.copy(isSelected = !isSelected) + taskList[index] = item.copy(isSelected = !isSelected) } fun deleteTask(task: Task) { - people.remove(task) + taskList.remove(task) } fun deleteFirstTask() { - people.removeAt(0) + taskList.removeAt(0) } fun addTaskToFirstIndex(task: Task) { - people.add(0, task) + taskList.add(0, task) } - } diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_11_6LazyListRecomposition6.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_11_6LazyListRecomposition6.kt new file mode 100644 index 00000000..326d51cd --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_11_6LazyListRecomposition6.kt @@ -0,0 +1,317 @@ +package com.smarttoolfactory.tutorial1_1basics.chapter4_state + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.Icon +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import com.smarttoolfactory.tutorial1_1basics.ui.backgroundColor +import com.smarttoolfactory.tutorial1_1basics.ui.components.TutorialHeader +import com.smarttoolfactory.tutorial1_1basics.ui.components.getRandomColor +import java.util.UUID + +@Preview +@Composable +fun Tutorial4_11Screen6() { + // 🔥 Adding item to the top recomposes every item as removing first item + // 🔥 Using key keeps scroll position when items added or removed before visible item + TutorialContent() +} + +@Composable +private fun TutorialContent() { + Column( + modifier = Modifier + .background(backgroundColor) + .fillMaxSize() + .padding(10.dp) + ) { + TutorialHeader(text = "LazyList Recomposition6") + + val viewModel = AddRemoveSwapViewModel() + MainScreen(viewModel = viewModel) + } +} + +@Composable +private fun MainScreen( + viewModel: AddRemoveSwapViewModel, +) { + + val onClick = remember { + { index: Int -> + viewModel.toggleSelection(index) + } + } + + val onLongClick = remember { + { task: EditableTask -> + viewModel.deleteTask(task) + } + } + + val swap = remember { + { firstIndex: Int, secondIndex: Int -> + viewModel.swap(firstIndex, secondIndex) + } + } + + var firstIndex by remember { + mutableIntStateOf(0) + } + + var secondIndex by remember { + mutableIntStateOf(4) + } + + Column( + modifier = Modifier.padding(8.dp), + + ) { + val tasks = viewModel.taskList + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + val id = UUID.randomUUID().toString().take(12) + viewModel.addTaskToFirstIndex( + EditableTask( + id = id, + title = "Task $id", + editTime = System.currentTimeMillis() + ) + ) + } + ) { + Text("Add Task to Top") + } + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + tasks.lastOrNull()?.apply { + viewModel.deleteFirstTask() + } + } + ) { + Text("Delete Task from Top") + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row { + OutlinedTextField( + modifier = Modifier.weight(1f), + label = { + Text("First Index") + }, + value = "$firstIndex", + onValueChange = { + it.toIntOrNull()?.let { + firstIndex = it + } + } + ) + + Spacer(modifier = Modifier.width(8.dp)) + + OutlinedTextField( + modifier = Modifier.weight(1f), + label = { + Text("Second Index") + }, + value = "$secondIndex", + onValueChange = { + it.toIntOrNull()?.let { + secondIndex = it + } + } + ) + } + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + swap(firstIndex, secondIndex) + } + ) { + Text("Swap $firstIndex with $secondIndex") + } + + ListScreen( + tasks = tasks, + onItemClick = onClick, + onItemLongClick = onLongClick + + ) + } +} + +@Composable +private fun ListScreen( + tasks: List, + onItemClick: (Int) -> Unit, + onItemLongClick: (EditableTask) -> Unit, +) { + + SideEffect { + println("ListScreen is recomposing...$tasks") + } + + Column { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxSize() + .border(3.dp, getRandomColor(), RoundedCornerShape(8.dp)) + ) { + itemsIndexed( + items = tasks, + // 🔥 Using key keeps scroll position when items added or + // removed before visible item + key = { _: Int, task: EditableTask -> + task.id + } + ) { index, task -> + TaskListItem( + item = task, + onItemClick = { + onItemClick(index) + }, + onItemLongClick = onItemLongClick + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TaskListItem( + modifier: Modifier = Modifier, + item: EditableTask, + onItemClick: () -> Unit, + onItemLongClick: (EditableTask) -> Unit, +) { + + SideEffect { + println("Recomposing ${item.title}, selected: ${item.isSelected}") + } + + Column( + modifier = modifier + .shadow(2.dp, RoundedCornerShape(8.dp)) + .border(2.dp, getRandomColor(), RoundedCornerShape(8.dp)) + .background(Color.White) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + onItemClick() + }, + onLongClick = { + onItemLongClick(item) + } + ) + .padding(8.dp) + ) { + Text("${item.title}, id: ${item.id.substring(startIndex = item.id.length - 6)}", fontSize = 20.sp) + + if (item.isSelected) { + Icon( + modifier = Modifier + .align(Alignment.CenterEnd) + .background(Color.Red, CircleShape), + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = Color.Green, + ) + } + } + } +} + +private class AddRemoveSwapViewModel : ViewModel() { + + private val initialList = List(8) { index: Int -> + val id = UUID.randomUUID().toString().apply { + substring(startIndex = lastIndex - 6) + } + EditableTask(id = id, title = "Task: $index", editTime = System.currentTimeMillis()) + } + + val taskList = mutableStateListOf().apply { + addAll(initialList) + } + + fun toggleSelection(index: Int) { + val item = taskList[index] + val isSelected = item.isSelected + taskList[index] = item.copy(isSelected = !isSelected, editTime = System.currentTimeMillis()) + taskList.sortByDescending { it.editTime } + } + + fun swap(firsTaskIndex: Int, secondTaskIndex: Int) { + val firstTask = taskList[firsTaskIndex] + val secondTask = taskList[secondTaskIndex] + taskList[firsTaskIndex] = secondTask + taskList[secondTaskIndex] = firstTask + } + + fun deleteTask(task: EditableTask) { + taskList.remove(task) + } + + fun deleteFirstTask() { + taskList.removeAt(0) + } + + fun addTaskToFirstIndex(task: EditableTask) { + taskList.add(0, task) + } +} + +internal data class EditableTask( + val id: String, + val title: String, + val isSelected: Boolean = false, + val editTime: Long, +) diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_11_6LazyListScrollDirection.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_12LazyListScrollDirection.kt similarity index 99% rename from Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_11_6LazyListScrollDirection.kt rename to Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_12LazyListScrollDirection.kt index 010c1edf..0024d96e 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_11_6LazyListScrollDirection.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_12LazyListScrollDirection.kt @@ -42,7 +42,7 @@ import kotlinx.coroutines.launch @Preview @Composable -fun Tutorial4_11Screen6() { +fun Tutorial4_12Screen() { Column { TutorialHeader("LazyList Scroll Direction") TutorialContent() diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_13AnimatingKeyEquality.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_13AnimatingKeyEquality.kt new file mode 100644 index 00000000..997709c6 --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_13AnimatingKeyEquality.kt @@ -0,0 +1,250 @@ +package com.smarttoolfactory.tutorial1_1basics.chapter4_state + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import com.smarttoolfactory.tutorial1_1basics.ui.backgroundColor +import kotlinx.coroutines.delay +import kotlin.random.Random + +@Preview +@Composable +private fun FlowRowCompositionPreview() { + val viewModel = viewModel() + MyComposable(viewModel) +} + +@Composable +fun MyComposable(someViewModel: SomeViewModel) { + + Column(modifier = Modifier.fillMaxSize().background(backgroundColor)) { + + val itemList = someViewModel.itemList + + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp) + ) { + + items(5) { + Box( + modifier = Modifier + .background(Color.Red, RoundedCornerShape(16.dp)) + .fillMaxWidth().height(100.dp) + ) + } + + item { + Button( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + onClick = { + someViewModel.filter() + } + ) { + Text("Filter") + } + } + item { + StaggeredList( + filter = someViewModel.filter.toString(), + itemList = itemList + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun StaggeredList( + filter: String, + itemList: List, +) { + BoxWithConstraints( + modifier = Modifier + ) { + + val itemWidth = (maxWidth - 8.dp) / 2 + + val heightList by remember { + mutableStateOf( + List(10) { index -> + if (index == 0) { + 200.dp + } else Random.nextInt(120, 200).dp + } + ) + } + + var height by remember { + mutableStateOf(0.dp) + } + + val density = LocalDensity.current + + FlowRow( + modifier = Modifier + .onGloballyPositioned { + val newHeight = it.size.height + if (newHeight != 0) { + height = with(density) { + newHeight.toDp() + } + } + } + .heightIn(min = height) + .border(2.dp, Color.Green), + maxItemsInEachRow = 2, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemList.forEachIndexed { index, it -> + + // 🔥 These keys are unique for items for every filtering + // without filter if items exist from previous filtering + // they don't leave recomposition + key(it.id + filter) { + + var visible by remember { + mutableStateOf(false) + } + + LaunchedEffect(filter) { + visible = false + delay(250) + visible = true + height = 0.dp + } + + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(250)) + + slideInVertically(tween(250)) { + it / 2 + }, + exit = fadeOut(tween(250)) + slideOutVertically(tween(250)) { + it / 2 + } + ) { + MyRow( + modifier = Modifier.size(itemWidth, heightList[index]), + item = it + ) + } + } + } + } + } +} + +@Composable +fun MyRow( + modifier: Modifier = Modifier, + item: SomeData, +) { + + var counter by remember { + mutableIntStateOf(0) + } + + Column( + modifier = modifier + .shadow(2.dp, RoundedCornerShape(16.dp)) + .background(Color.White, RoundedCornerShape(16.dp)) + .padding(16.dp) + ) { + Text( + "id: ${item.id}, value: ${item.value}" + ) + Spacer(modifier = Modifier.weight(1f)) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + counter++ + } + ) { + Text("Counter: $counter") + } + } +} + +class SomeViewModel : ViewModel() { + + private val list = listOf( + SomeData(id = "1", value = "Row1"), + SomeData(id = "2", value = "Row2"), + SomeData(id = "3", value = "Row3"), + SomeData(id = "4", value = "Row4"), + SomeData(id = "5", value = "Row5") + ) + + var itemList by mutableStateOf(list) + + var filter: Int = 0 + + fun filter() { + if (filter % 3 == 0) { + itemList = listOf( + list[0], + list[1], + list[2] + ) + } else if (filter % 3 == 1) { + itemList = listOf( + list[1], + list[2] + ) + } else { + itemList = listOf( + list[0], + list[2], + list[3] + ) + } + + filter++ + } +} + +data class SomeData(val id: String, val value: String) \ No newline at end of file diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter5_gesture/Tutorial5_16SafeThrottleClick.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter5_gesture/Tutorial5_16SafeThrottleClick.kt new file mode 100644 index 00000000..c19cc631 --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter5_gesture/Tutorial5_16SafeThrottleClick.kt @@ -0,0 +1,131 @@ +package com.smarttoolfactory.tutorial1_1basics.chapter5_gesture + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.smarttoolfactory.tutorial1_1basics.ui.components.StyleableTutorialText +import com.smarttoolfactory.tutorial1_1basics.ui.components.TutorialHeader + +@Preview +@Composable +fun Tutorial5_16Screen() { + TutorialContent() +} + +@Composable +private fun TutorialContent() { + Column( + modifier = Modifier.fillMaxSize() + ) { + + TutorialHeader("Throttled Click") + StyleableTutorialText( + text = "In this sample second button uses custom **throttleClick** Modifier to invoke clicks initially and" + + " only after timeOut is passed for subsequent clicks.", + bullets = false + ) + SafeClickSample() + } +} + +fun Modifier.throttleClick( + timeout: Long = 300, + pass: PointerEventPass = PointerEventPass.Main, + onClick: () -> Unit, +) = composed { + Modifier.pointerInput(timeout) { + + var lastDownTime = 0L + + awaitEachGesture { + val down = awaitFirstDown( + pass = pass + ) + + println( + "down-> uptimeMillis: ${down.uptimeMillis}, " + + "previous: ${down.previousUptimeMillis}, " + + "diff: ${down.uptimeMillis - down.previousUptimeMillis}" + ) + + val uptimeMillis = down.uptimeMillis + + + val diff = (uptimeMillis - lastDownTime) + + val up = waitForUpOrCancellation( + pass = pass + ) + up?.let { + // 🔥if pointer is held change time between previousUptimeMillis and uptimeMillis changes + // 🔥up.previousUptimeMillis is equal to down.uptimeMillis if pointer is not moved since + // waitForUpOrCancellation uses awaitPointerEvent to get updates only when pointer is moved + println( + "up-> uptimeMillis: ${up.uptimeMillis}, " + + "previous: ${up.previousUptimeMillis}, " + + "diff: ${up.uptimeMillis - up.previousUptimeMillis}" + ) + + if (diff>= timeout) { + onClick() + lastDownTime = uptimeMillis + } + } + } + } +} + +@Preview +@Composable +private fun SafeClickSample() { + + var counter1 by remember { + mutableIntStateOf(0) + } + + var counter2 by remember { + mutableIntStateOf(0) + } + + Column( + modifier = Modifier.fillMaxSize().padding(16.dp) + ) { + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + counter1++ + } + ) { + Text("Default Counter: $counter1") + } + + Button( + modifier = Modifier.fillMaxWidth().throttleClick( + pass = PointerEventPass.Initial + ) { + counter2++ + }, + onClick = {} + ) { + Text("Throttled Counter: $counter2") + } + } +} diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter5_gesture/Tutorial5_2TapDragGesture.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter5_gesture/Tutorial5_2TapDragGesture.kt index b6a02144..f41c259b 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter5_gesture/Tutorial5_2TapDragGesture.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter5_gesture/Tutorial5_2TapDragGesture.kt @@ -262,7 +262,8 @@ private fun DetectDragGesturesCycleExample() { id: ${change.id} type: ${change.type} uptimeMillis: ${change.uptimeMillis} - previousUptimeMillis: ${change.previousUptimeMillis} + previousUptimeMillis: ${change.previousUptimeMillis}, + diffUpTimeMillis: ${change.uptimeMillis - change.previousUptimeMillis} position: ${change.position}, previousPosition: ${change.previousPosition} """.trimIndent() diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter5_gesture/Tutorial5_4_1AwaitPointerEventScope.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter5_gesture/Tutorial5_4_1AwaitPointerEventScope.kt index c7e7aaaf..5367dd5c 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter5_gesture/Tutorial5_4_1AwaitPointerEventScope.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter5_gesture/Tutorial5_4_1AwaitPointerEventScope.kt @@ -179,6 +179,8 @@ private fun AwaitPointerEventExample() { "positionChange(): ${pointerInputChange.positionChange()}\n" + "positionChangeIgnoreConsumed(): ${pointerInputChange.positionChangeIgnoreConsumed()}\n" + "uptimeMillis: ${pointerInputChange.uptimeMillis}\n" + + "previousUptimeMillis: ${pointerInputChange.previousUptimeMillis}\n" + + "diff: ${pointerInputChange.uptimeMillis - pointerInputChange.previousUptimeMillis}" + "previousPressed: ${pointerInputChange.previousPressed}" } @@ -260,6 +262,8 @@ private fun AwaitPointerEventExample2() { "positionChange(): ${pointerInputChange.positionChange()}\n" + "positionChangeIgnoreConsumed(): ${pointerInputChange.positionChangeIgnoreConsumed()}\n" + "uptimeMillis: ${pointerInputChange.uptimeMillis}\n" + + "previousUptimeMillis: ${pointerInputChange.previousUptimeMillis}\n" + + "diff: ${pointerInputChange.uptimeMillis - pointerInputChange.previousUptimeMillis}\n" + "previousPressed: ${pointerInputChange.previousPressed}" } diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/ParticlePhysics.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/ParticlePhysics.kt new file mode 100644 index 00000000..3a70515c --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/ParticlePhysics.kt @@ -0,0 +1,252 @@ +package com.smarttoolfactory.tutorial1_1basics.chapter6_graphics + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Slider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.util.Random + +@Preview +@Composable +fun ControlledExplosion() { + + var progress by remember { mutableFloatStateOf(0f) } + + var visibilityThresholdLow by remember { + mutableFloatStateOf(0f) + } + + var visibilityThresholdHigh by remember { + mutableFloatStateOf(1f) + } + + val particleCount = 100 + + val density = LocalDensity.current + + val sizeDp = with(density) { + 1000f.toDp() + } + val sizePx = with(density) { + sizeDp.toPx() + } + val sizePxHalf = sizePx / 2 + + val particles = remember( + visibilityThresholdLow, + visibilityThresholdHigh + ) { + List(particleCount) { + + val initialDisplacementX: Float = 1f + val initialDisplacementY: Float = 1f + + +// with(density) { +// initialDisplacementX = 10.dp.toPx() * randomInRange(-1f, 1f) +// initialDisplacementY = 10.dp.toPx() * randomInRange(-1f, 1f) +// } + + val min = randomInRange(0f, visibilityThresholdLow) + val max = randomInRange(min, visibilityThresholdHigh) + + ExplodingParticle( + color = Color(listOf(0xffea4335, 0xff4285f4, 0xfffbbc05, 0xff34a853).random()), + startXPosition = sizePxHalf.toInt(), + startYPosition = sizePxHalf.toInt(), + maxHorizontalDisplacement = sizePxHalf * randomInRange(-.9f, .9f), + maxVerticalDisplacement = sizePxHalf * randomInRange(0.2f, 0.38f), + visibilityThresholdLow = min, + visibilityThresholdHigh = max, + initialDisplacementX = initialDisplacementX, + initialDisplacementY = initialDisplacementY + ) + } + } + + Column( + modifier = Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(vertical = 16.dp, horizontal = 8.dp), + ) { + + Explosion( + progress = progress, + particles = particles, + sizeDp = sizeDp + ) + + Spacer(Modifier.height(16.dp)) + + val particle = particles.first() + Text( + text = "Progress: ${(progress * 100).toInt() / 100f}\n" + + "trajectory: ${(particle.trajectoryProgress * 100).toInt() / 100f}\n" + + "currentTime: ${(particle.currentTime * 100f).toInt() / 100f}\n", + fontSize = 18.sp + ) + Slider( + modifier = Modifier.fillMaxWidth(), + value = progress, + onValueChange = { + progress = it + } + ) + + Text("visibilityThresholdLow: $visibilityThresholdLow") + Slider( + modifier = Modifier.fillMaxWidth(), + value = visibilityThresholdLow, + onValueChange = { + visibilityThresholdLow = it.coerceAtMost(visibilityThresholdHigh) + }, + valueRange = 0f..1f + ) + + Text("visibilityThresholdHigh: $visibilityThresholdHigh") + Slider( + modifier = Modifier.fillMaxWidth(), + value = visibilityThresholdHigh, + onValueChange = { + visibilityThresholdHigh = it.coerceAtLeast(visibilityThresholdLow) + }, + valueRange = 0f..1f + ) + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +fun Explosion( + sizeDp: Dp, + particles: List, + progress: Float +) { + val density = LocalDensity.current + val sizePx = with(density) { + sizeDp.toPx() + } + + val sizePxHalf = sizePx / 2 + + particles.forEach { it.updateProgress(progress) } + + Canvas( + modifier = Modifier + .border(width = 1.dp, color = Color(0x26000000)) + .size(sizeDp) + ) { + drawLine( + color = Color.Black, + start = Offset(sizePxHalf, 0f), + end = Offset(sizePxHalf, sizePx), + strokeWidth = 2.dp.toPx() + ) + drawLine( + color = Color.Black, + start = Offset(0f, sizePxHalf), + end = Offset(sizePx, sizePxHalf), + strokeWidth = 2.dp.toPx() + ) + particles.forEach { particle -> + drawCircle( + alpha = particle.alpha, + color = particle.color, + radius = 5.dp.toPx(), + center = Offset(particle.currentXPosition, particle.currentYPosition), + ) + } + } +} + +class ExplodingParticle( + val color: Color, + val startXPosition: Int, + val startYPosition: Int, + val maxHorizontalDisplacement: Float, + val maxVerticalDisplacement: Float, + val visibilityThresholdLow: Float, + val visibilityThresholdHigh: Float, + val initialDisplacementX: Float, + val initialDisplacementY: Float +) { + private val velocity = 4 * maxVerticalDisplacement + private val acceleration = -2 * velocity + var currentXPosition = 0f + var currentYPosition = 0f + + var alpha = 1f + + var currentTime: Float = 0f + private set + var trajectoryProgress: Float = 0f + private set + + fun updateProgress(explosionProgress: Float) { + + // Trajectory progress translates progress from 0f-1f to + // visibilityThresholdLow-visibilityThresholdHigh + // range. For instance, 0.1-0.6f range movement starts when + // explosionProgress is at 0.1f and reaches 1f + // when explosionProgress reaches 0.6f and trajectoryProgress . + + // Each 0.1f change in trajectoryProgress 0.5f total range + // corresponds to 0.2f change of current time + trajectoryProgress = + if (explosionProgress < visibilityThresholdLow) { + 0f + } else if (explosionProgress> visibilityThresholdHigh) { + 1f + } else { + scale( + a1 = visibilityThresholdLow, + b1 = visibilityThresholdHigh, + x1 = explosionProgress, + a2 = 0f, + b2 = 1f + ) + } + + currentTime = scale(0f, 1f, trajectoryProgress, 0f, 1.4f) + + // While trajectory progress is less than 70% have full alpha then slowly cre + alpha = if (trajectoryProgress < .7f) 1f else + scale(.7f, 1f, trajectoryProgress, 1f, 0f) + + val verticalDisplacement = + currentTime * velocity + 0.5 * acceleration * currentTime * currentTime + + currentXPosition = + startXPosition + initialDisplacementX + maxHorizontalDisplacement * trajectoryProgress + currentYPosition = (startYPosition + initialDisplacementY - verticalDisplacement).toFloat() + + } +} + +private val random = Random() +fun Float.randomTillZero() = this * random.nextFloat() +fun randomInRange(min: Float, max: Float) = min + (max - min).randomTillZero() +fun randomBoolean(trueProbabilityPercentage: Int) = + random.nextFloat() < trueProbabilityPercentage / 100f \ No newline at end of file diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/PieChartLabels.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/PieChartLabels.kt new file mode 100644 index 00000000..7a808e37 --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/PieChartLabels.kt @@ -0,0 +1,214 @@ +package com.smarttoolfactory.tutorial1_1basics.chapter6_graphics + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Slider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.toSize +import com.smarttoolfactory.tutorial1_1basics.ui.Blue400 +import com.smarttoolfactory.tutorial1_1basics.ui.Orange400 +import com.smarttoolfactory.tutorial1_1basics.ui.Pink400 +import com.smarttoolfactory.tutorial1_1basics.ui.Yellow400 +import kotlin.math.cos +import kotlin.math.sin + +@Preview +@Composable +private fun PieChartWithText() { + + Column( + modifier = Modifier.fillMaxSize().padding(16.dp) + ) { + + var chartStartAngle by remember { + mutableFloatStateOf(0f) + } + + Text("Chart Start angle: ${chartStartAngle.toInt()}") + Slider( + value = chartStartAngle, + onValueChange = { + chartStartAngle = it + }, + valueRange = 0f..360f + ) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + contentAlignment = Alignment.Center + ) { + val chartDataList = listOf( + PieChartData(Pink400, 10f), + PieChartData(Orange400, 30f), + PieChartData(Yellow400, 40f), + PieChartData(Blue400, 20f) + ) + + val textMeasurer = rememberTextMeasurer() + val textMeasureResults = remember(chartDataList) { + chartDataList.map { + textMeasurer.measure( + text = "${it.data.toInt()}%", + style = TextStyle( + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + ) + } + } + + Canvas( + modifier = Modifier + .padding(24.dp) + .fillMaxWidth() + .aspectRatio(1f) + ) { + val width = size.width + val radius = width * .22f + val strokeWidth = radius * .6f + val outerRadius = radius + strokeWidth + strokeWidth / 2 + val diameter = (radius + strokeWidth) * 2 + val topLeft = (width - diameter) / 2f + + var startAngle = chartStartAngle + + for (index in 0..chartDataList.lastIndex) { + + startAngle %= 360 + + val chartData = chartDataList[index] + val sweepAngle = chartData.data.asAngle + val textMeasureResult = textMeasureResults[index] + val textSize = textMeasureResult.size + + val offset = 10.dp.toPx() + + drawArc( + color = chartData.color, + startAngle = startAngle, + sweepAngle = sweepAngle, + useCenter = false, + topLeft = Offset(topLeft, topLeft), + size = Size(diameter, diameter), + style = Stroke(strokeWidth) + ) + + val rect = textMeasureResult.getBoundingBox(0) + + val adjustedAngle = (startAngle) % 360 + + val cos = cos(adjustedAngle.degreeToRadian) + val sin = sin(adjustedAngle.degreeToRadian) + + val textOffset = getTextOffsets(startAngle, textSize) + val textOffsetX = textOffset.x + val textOffsetY = textOffset.y + + drawCircle( + color = Color.Blue, + radius = outerRadius, + style = Stroke(2.dp.toPx()) + ) + + drawCircle( + color = Color.Magenta, + radius = outerRadius + offset, + style = Stroke(2.dp.toPx()) + ) + + drawRect( + color = Color.Black, + topLeft = Offset( + x = rect.topLeft.x + center.x + textOffsetX + (offset + outerRadius) * cos, + y = rect.topLeft.y + center.y + textOffsetY + (offset + outerRadius) * sin + ), + size = textSize.toSize(), + style = Stroke(3.dp.toPx()) + ) + + drawText( + textLayoutResult = textMeasureResult, + color = Color.DarkGray, + topLeft = Offset( + x = center.x + textOffsetX + (offset + outerRadius) * cos, + y = center.y + textOffsetY + (offset + outerRadius) * sin + ) + ) + + startAngle += sweepAngle + } + } + } + } + +} + +private fun getTextOffsets(startAngle: Float, textSize: IntSize): Offset { + var textOffsetX: Int = 0 + var textOffsetY: Int = 0 + + when (startAngle) { + in 0f..90f -> { + textOffsetX = if (startAngle < 60) 0 + else (-textSize.width / 2 * ((startAngle - 60) / 30)).toInt() + + textOffsetY = 0 + } + + in 90f..180f -> { + textOffsetX = (-textSize.width / 2 - textSize.width / 2 * (startAngle - 90f) / 45).toInt() + .coerceAtLeast(-textSize.width) + + textOffsetY = if (startAngle < 135) 0 + else (-textSize.height / 2 * ((startAngle - 135) / 45)).toInt() + } + + in 180f..270f -> { + textOffsetX = if (startAngle < 240) -textSize.width + else (-textSize.width + textSize.width / 2 * (startAngle - 240) / 30).toInt() + + textOffsetY = if (startAngle < 225) (-textSize.height / 2 * ((startAngle - 135) / 45)).toInt() + else -textSize.height + } + + else -> { + textOffsetX = + if (startAngle < 315) (-textSize.width / 2 + (textSize.width / 2) * (startAngle - 270) / 45).toInt() + else 0 + + textOffsetY = if (startAngle < 315) -textSize.height + else (-textSize.height + textSize.height * (startAngle - 315) / 45).toInt() + } + } + return Offset(textOffsetX.toFloat(), textOffsetY.toFloat()) +} + +@Immutable +private data class PieChartData(val color: Color, val data: Float) diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial40_2RenderEffect1.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial40_2RenderEffect1.kt new file mode 100644 index 00000000..6b8c1301 --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial40_2RenderEffect1.kt @@ -0,0 +1,769 @@ +package com.smarttoolfactory.tutorial1_1basics.chapter6_graphics + +import android.graphics.BitmapShader +import android.graphics.RenderEffect +import android.graphics.RenderNode +import android.graphics.RuntimeShader +import android.graphics.Shader +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.CanvasHolder +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asComposeRenderEffect +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.imageResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.roundToIntSize +import androidx.compose.ui.unit.sp +import com.smarttoolfactory.tutorial1_1basics.R +import com.smarttoolfactory.tutorial1_1basics.ui.backgroundColor +import com.smarttoolfactory.tutorial1_1basics.ui.components.StyleableTutorialText +import com.smarttoolfactory.tutorial1_1basics.ui.components.TutorialHeader +import com.smarttoolfactory.tutorial1_1basics.ui.components.TutorialText2 +import org.intellij.lang.annotations.Language +import kotlin.math.roundToInt + +@Preview +@Composable +fun Tutorial6_40Screen2() { + TutorialContent() +} + +@Composable +private fun TutorialContent() { + Column(modifier = Modifier.padding(8.dp)) { + TutorialHeader(text = "RenderEffect") + StyleableTutorialText( + text = "Blue Composables using **RenderEffect.createBlurEffect** with varying values " + + "and with different edgeTreatments.", + bullets = false + ) + + RenderEffectBlurSample() + } +} + +@Preview +@Composable +private fun RenderEffectBlurSample() { + + var blurRadiusX by remember { + mutableFloatStateOf(10f) + } + + var blurRadiusY by remember { + mutableFloatStateOf(10f) + } + + if (Build.VERSION.SDK_INT>= Build.VERSION_CODES.S) { + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(8.dp), + ) { + TutorialText2( + text = "Shader.TileMode.MIRROR", + modifier = Modifier.padding(vertical = 16.dp) + ) + Image( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .width(260.dp) + .border(2.dp, Color.Red) + .graphicsLayer { + renderEffect = RenderEffect.createBlurEffect( + blurRadiusX, blurRadiusY, + Shader.TileMode.MIRROR + ).asComposeRenderEffect() + + }, + painter = painterResource(R.drawable.landscape10), + contentDescription = null, + contentScale = ContentScale.FillBounds + ) + + TutorialText2( + text = "Shader.TileMode.DECAL", + modifier = Modifier.padding(vertical = 16.dp) + ) + Image( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .width(260.dp) + .border(2.dp, Color.Red) + .graphicsLayer { + renderEffect = RenderEffect.createBlurEffect( + blurRadiusX, blurRadiusY, + Shader.TileMode.DECAL + ).asComposeRenderEffect() + + }, + painter = painterResource(R.drawable.landscape10), + contentDescription = null, + contentScale = ContentScale.FillBounds + ) + + TutorialText2( + text = "Shader.TileMode.CLAMP", + modifier = Modifier.padding(vertical = 16.dp) + ) + Image( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .width(260.dp) + .border(2.dp, Color.Red) + .graphicsLayer { + renderEffect = RenderEffect.createBlurEffect( + blurRadiusX, blurRadiusY, + Shader.TileMode.CLAMP + ).asComposeRenderEffect() + + }, + painter = painterResource(R.drawable.landscape10), + contentDescription = null, + contentScale = ContentScale.FillBounds + ) + + TutorialText2( + text = "Shader.TileMode.REPEAT", + modifier = Modifier.padding(vertical = 16.dp) + ) + Image( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .width(260.dp) + .border(2.dp, Color.Red) + .graphicsLayer { + renderEffect = RenderEffect.createBlurEffect( + blurRadiusX, blurRadiusY, + Shader.TileMode.REPEAT + ).asComposeRenderEffect() + + }, + painter = painterResource(R.drawable.landscape10), + contentDescription = null, + contentScale = ContentScale.FillBounds + ) + + + Text("Blur radiusX: ${blurRadiusX.roundToInt()}") + + Slider( + modifier = Modifier.padding(horizontal = 16.dp), + value = blurRadiusX, + onValueChange = { + blurRadiusX = it + }, + valueRange = 0.01f..25f + ) + + Text("Blur radiusY: ${blurRadiusY.roundToInt()}") + + Slider( + modifier = Modifier.padding(horizontal = 16.dp), + value = blurRadiusY, + onValueChange = { + blurRadiusY = it + }, + valueRange = 0.01f..25f + ) + } + } +} + + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Preview +@Composable +fun GradientShaderBrushSample() { + + val COLOR_SHADER_SRC = + """uniform float2 iResolution; + half4 main(float2 fragCoord) { + float2 scaled = fragCoord/iResolution.xy; + return half4(scaled, 0, 1); + }""" + + Canvas(modifier = Modifier.fillMaxWidth().aspectRatio(4 / 3f).border(2.dp, Color.Red)) { + val colorShader = RuntimeShader(COLOR_SHADER_SRC) + + colorShader.setFloatUniform("iResolution", size.width, size.height) + val shaderBrush = ShaderBrush(colorShader) + drawCircle(brush = shaderBrush) + } + +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Preview +@Composable +fun ImageRenderEffectSample() { + val hueShader = remember { + RuntimeShader( + """ + uniform float2 iResolution; // Viewport resolution (pixels) + uniform float2 iImageResolution; // iImage1 resolution (pixels) + uniform float iRadian; // radian to rotate things around + uniform shader iImage1; // An input image + half4 main(float2 fragCoord) { + float cosR = cos(iRadian); + float sinR = sin(iRadian); + mat4 hueRotation = + mat4 ( + 0.299 + 0.701 * cosR + 0.168 * sinR, //0 + 0.587 - 0.587 * cosR + 0.330 * sinR, //1 + 0.114 - 0.114 * cosR - 0.497 * sinR, //2 + 0.0, //3 + 0.299 - 0.299 * cosR - 0.328 * sinR, //4 + 0.587 + 0.413 * cosR + 0.035 * sinR, //5 + 0.114 - 0.114 * cosR + 0.292 * sinR, //6 + 0.0, //7 + 0.299 - 0.300 * cosR + 1.25 * sinR, //8 + 0.587 - 0.588 * cosR - 1.05 * sinR, //9 + 0.114 + 0.886 * cosR - 0.203 * sinR, //10 + 0.0, //11 + 0.0, 0.0, 0.0, 1.0 ); //12,13,14,15 + float2 scale = iImageResolution.xy / iResolution.xy; + return iImage1.eval(fragCoord * scale)*hueRotation; + } +""" + ) + } + + val bitmap = ImageBitmap.imageResource(R.drawable.avatar_1_raster) + + Column( + modifier = Modifier.fillMaxSize() + ) { + + hueShader.setFloatUniform( + "iImageResolution", bitmap.width.toFloat(), + bitmap.height.toFloat() + ) + hueShader.setFloatUniform( + "iResolution", bitmap.width.toFloat(), + bitmap.height.toFloat() + ) + hueShader.setFloatUniform("iRadian", 20f) + hueShader.setInputShader( + "iImage1", BitmapShader( + bitmap.asAndroidBitmap(), Shader.TileMode.MIRROR, + Shader.TileMode.MIRROR + ) + ) + Canvas( + modifier = Modifier + .border(2.dp, Color.Green) + .clipToBounds() + .fillMaxWidth() + .aspectRatio(4 / 3f) + .graphicsLayer { + renderEffect = RenderEffect.createShaderEffect(hueShader) + .asComposeRenderEffect() + } + ) { + drawImage(image = bitmap) + } + + } +} + +//@RequiresApi(Build.VERSION_CODES.TIRAMISU) +//@Language("GLSL") +//val FROSTED_GLASS_SHADER = RuntimeShader( +// """ +// uniform shader inputShader; +// uniform float height; +// uniform float width; +// +// vec4 main(vec2 coords) { +// vec4 currValue = inputShader.eval(coords); +// float top = height - 100; +// if (coords.y < top) { +// return currValue; +// } else { +// // Avoid blurring edges +// if (coords.x> 1 && coords.y> 1 && +// coords.x < (width - 1) && +// coords.y < (height - 1)) { +// // simple box blur - average 5x5 grid around pixel +// vec4 boxSum = +// inputShader.eval(coords + vec2(-2, -2)) + +// // ... +// currValue + +// // ... +// inputShader.eval(coords + vec2(2, 2)); +// currValue = boxSum / 25; +// } +// +// const vec4 white = vec4(1); +// // top-left corner of label area +// vec2 lefttop = vec2(0, top); +// float lightenFactor = min(1.0, .6 * +// length(coords - lefttop) / +// (0.85 * length(vec2(width, 100)))); +// // White in upper-left, blended increasingly +// // toward lower-right +// return mix(currValue, white, 1 - lightenFactor); +// } +// } +//""" +//) +// +//@RequiresApi(Build.VERSION_CODES.TIRAMISU) +//@Preview +//@Composable +//fun FrostedGlassSample() { +// +// Column( +// modifier = Modifier.fillMaxSize() +// ) { +// +// Image( +// modifier = Modifier +// .fillMaxWidth() +// .aspectRatio(4 / 3f), +// painter = painterResource(R.drawable.landscape11), +// contentDescription = null, +// contentScale = ContentScale.FillBounds +// ) +// +// Image( +// modifier = Modifier +// .fillMaxWidth() +// .border(2.dp, Color.Red) +// .aspectRatio(4 / 3f) +// .graphicsLayer { +// val width = size.width +// val height = size.height +// +// FROSTED_GLASS_SHADER.setFloatUniform("height", height) +// +// val blur = RenderEffect.createBlurEffect(5f, 5f, Shader.TileMode.CLAMP) +// +// val effect = RenderEffect.createRuntimeShaderEffect( +// FROSTED_GLASS_SHADER, "inputShader" +// ) +// +// renderEffect = +// RenderEffect.createChainEffect(blur, effect).asComposeRenderEffect() +// }, +// painter = painterResource(R.drawable.landscape11), +// contentDescription = null, +// contentScale = ContentScale.FillBounds +// ) +// +// } +//} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Preview +@Composable +fun GlassEffectSample() { + + val density = LocalDensity.current.density + + Canvas( + modifier = Modifier + .background(Color.Black) + .fillMaxSize(1.0f) + .graphicsLayer { + val shader = RuntimeShader(compositeSksl) + shader.setFloatUniform( + "rectangle", + 85.0f * density, 110.0f * density, 405.0f * density, 290.0f * density + ) + shader.setFloatUniform("radius", 20.0f * density) + // What should i set for content and blur for shaders? + + } + ) { + drawCircle( + brush = Brush.linearGradient( + colors = listOf(Color(0xFF7A26D9), Color(0xFFE444E1)), + start = Offset(450.dp.toPx(), 60.dp.toPx()), + end = Offset(290.dp.toPx(), 190.dp.toPx()), + tileMode = TileMode.Clamp + ), + center = Offset(375.dp.toPx(), 125.dp.toPx()), + radius = 100.dp.toPx() + ) + drawCircle( + color = Color(0xFFEA357C), + center = Offset(100.dp.toPx(), 265.dp.toPx()), + radius = 55.dp.toPx() + ) + drawCircle( + brush = Brush.linearGradient( + colors = listOf(Color(0xFFEA334C), Color(0xFFEC6051)), + start = Offset(180.dp.toPx(), 125.dp.toPx()), + end = Offset(230.dp.toPx(), 125.dp.toPx()), + tileMode = TileMode.Clamp + ), + center = Offset(205.dp.toPx(), 125.dp.toPx()), + radius = 25.dp.toPx() + ) + } +} + +// Recreate visuals from https://uxmisfit.com/2021/01/13/how-to-create-glassmorphic-card-ui-design/ +@Language("GLSL") +val compositeSksl = """ + uniform shader content; + uniform shader blur; + + uniform vec4 rectangle; + uniform float radius; + + // Simplified version of SDF (signed distance function) for a rounded box + // from https://www.iquilezles.org/www/articles/distfunctions2d/distfunctions2d.htm + float roundedRectangleSDF(vec2 position, vec2 box, float radius) { + vec2 q = abs(position) - box + vec2(radius); + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius; + } + + vec4 main(vec2 coord) { + vec2 shiftRect = (rectangle.zw - rectangle.xy) / 2.0; + vec2 shiftCoord = coord - rectangle.xy; + float distanceToClosestEdge = roundedRectangleSDF( + shiftCoord - shiftRect, shiftRect, radius); + + vec4 c = content.eval(coord); + if (distanceToClosestEdge> 0.0) { + // We're outside of the filtered area + return c; + } + + vec4 b = blur.eval(coord); + return b; + } +""" + +@RequiresApi(Build.VERSION_CODES.S) +@Preview +@Composable +fun RenderNodeSample() { + + val contentNode = remember { + RenderNode("contentNode") + } + + val topAppbarHeight = 180.dp + val canvasHolder: CanvasHolder = remember { + CanvasHolder() + } + + val graphicsLayer = rememberGraphicsLayer() + + val state = rememberLazyListState() + + Box { + LazyColumn( + state = state, + modifier = Modifier + .background(backgroundColor) + .fillMaxSize() + .drawWithContent { + + clipRect( + top = topAppbarHeight.toPx() + ) { + this@drawWithContent.drawContent() + } + + graphicsLayer.record( + size = IntSize(size.width.toInt(), topAppbarHeight.roundToPx()) + ) { + this@drawWithContent.drawContent() + } + }, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + + item { + Spacer(modifier = Modifier.height(topAppbarHeight)) + } + + items(100) { + + if (it == 5) { + Image( + modifier = Modifier.fillMaxWidth().aspectRatio(2f), + painter = painterResource(R.drawable.landscape11), + contentScale = ContentScale.Crop, + contentDescription = null + ) + } else { + Box( + modifier = Modifier.fillMaxWidth() + .background(Color.White, RoundedCornerShape(16.dp)) + .padding(16.dp) + ) { + Text("Row $it", fontSize = 22.sp) + } + } + } + } + + Box( + modifier = Modifier.fillMaxWidth() + + ) { + Canvas( + modifier = Modifier + .drawWithContent { + drawContent() + drawRect(color = Color.White.copy(alpha = .5f)) + } + .fillMaxWidth() + .height(topAppbarHeight) + ) { + contentNode.setPosition(0, 0, size.width.toInt(), size.height.toInt()) + contentNode.setRenderEffect( + RenderEffect.createBlurEffect( + 15f, 15f, + Shader.TileMode.CLAMP + ) + ) + + drawIntoCanvas { canvas: Canvas -> + val recordingCanvas = contentNode.beginRecording() + canvasHolder.drawInto(recordingCanvas) { + drawContext.also { + it.layoutDirection = layoutDirection + it.size = size + it.canvas = this + } + drawLayer(graphicsLayer) + } + + contentNode.endRecording() + + canvas.nativeCanvas.drawRenderNode(contentNode) + } + } + + Text( + "Glass Blur", + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + fontSize = 26.sp + ) + } + } +} + +fun Modifier.frostedGlass(height: Dp) = this.then( + if (Build.VERSION.SDK_INT>= Build.VERSION_CODES.S) { + Modifier.drawWithCache { + val topBarHeightPx = height.toPx() + + val blurLayer = obtainGraphicsLayer().apply { + renderEffect = RenderEffect + .createBlurEffect(16f, 16f, Shader.TileMode.DECAL) + .asComposeRenderEffect() + } + + onDrawWithContent { + blurLayer.record(size.copy(height = topBarHeightPx).roundToIntSize()) { + this@onDrawWithContent.drawContent() + } + + drawLayer(blurLayer) + clipRect(top = topBarHeightPx) { + this@onDrawWithContent.drawContent() + } + } + } + } else { + Modifier + } +) + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Preview(showBackground = true) +@Composable +fun PartialBlurTest() { + + val scrollState = rememberLazyListState() + val topBarHeight = 180.dp + + LazyColumn( + state = scrollState, + modifier = Modifier + .fillMaxSize() + .frostedGlass(height = topBarHeight), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + + item { + Spacer(modifier = Modifier.height(topBarHeight)) + } + + item { + LazyRow( + modifier = Modifier.fillMaxWidth().aspectRatio(2f), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(5) { + Image( + modifier = Modifier + .fillParentMaxWidth() + .aspectRatio(2f), + contentScale = ContentScale.Crop, + painter = painterResource(R.drawable.landscape11), contentDescription = null + ) + } + } + } + items(100) { + if (it == 5) { + Image( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(.5f), + painter = painterResource(R.drawable.landscape11), + contentScale = ContentScale.Crop, + contentDescription = null + ) + } else { + Box( + modifier = Modifier + .clickable { + + } + .fillMaxWidth() + .background(Color.White, RoundedCornerShape(16.dp)) + .padding(16.dp) + ) { + Text("Row $it", fontSize = 22.sp) + } + } + } + } +} + + +@Language("GLSL") +val CheapFrostedGlassTopBarAGSL = + """ + const vec4 white = vec4(1.0); + + uniform shader inputShader; + uniform float barHeight; + + vec4 main(vec2 coord) { + if (coord.y> barHeight) { + return inputShader.eval(coord); + } else { + vec2 factor = vec2(3.0); + + vec4 color = vec4(0.0); + color += inputShader.eval(coord - 3.0 * factor) * 0.0540540541; + color += inputShader.eval(coord - 2.0 * factor) * 0.1216216216; + color += inputShader.eval(coord - factor) * 0.1945945946; + color += inputShader.eval(coord) * 0.2270270270; + color += inputShader.eval(coord + factor) * 0.1945945946; + color += inputShader.eval(coord + 2.0 * factor) * 0.1216216216; + color += inputShader.eval(coord + 3.0 * factor) * 0.0540540541; + + return mix(color, white, 0.7); + } + } + """.trimIndent() + +val TopAppBarHeight = 180.dp + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Preview(showBackground = true) +@Composable +fun FrostedGlassTopBarTest() { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + val shader = RuntimeShader(CheapFrostedGlassTopBarAGSL) + shader.setFloatUniform("barHeight", TopAppBarHeight.toPx()) + val androidRenderEffect = android.graphics.RenderEffect + .createRuntimeShaderEffect(shader, "inputShader") + renderEffect = androidRenderEffect.asComposeRenderEffect() + } + ) { + items(100) { + if (it == 5) { + Image( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(2f), + painter = painterResource(R.drawable.landscape11), + contentScale = ContentScale.Crop, + contentDescription = null + ) + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.White, RoundedCornerShape(16.dp)) + .padding(16.dp) + ) { + Text("Row $it", fontSize = 22.sp) + } + } + } + } +} \ No newline at end of file diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_11EraseProgress.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_11EraseProgress.kt index 80b470cb..06fb5987 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_11EraseProgress.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_11EraseProgress.kt @@ -15,6 +15,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -45,9 +46,12 @@ import com.smarttoolfactory.tutorial1_1basics.chapter5_gesture.gesture.MotionEve import com.smarttoolfactory.tutorial1_1basics.chapter5_gesture.gesture.pointerMotionEvents import com.smarttoolfactory.tutorial1_1basics.ui.backgroundColor import com.smarttoolfactory.tutorial1_1basics.ui.components.StyleableTutorialText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlin.time.measureTime @Preview @Composable @@ -64,21 +68,28 @@ private fun TutorialContent() { .background(backgroundColor) ) { StyleableTutorialText( - text = "In this example using Canvas(imageBitmap) and reading pixels we compare" + - "which percentage of original image is erased.", + text = "In this example using **Canvas(imageBitmap)** and **reading pixels** " + + "we compare which percentage of original image is erased.", bullets = false ) - val imageBitmap = ImageBitmap.imageResource( + val sourceImageBitmap: ImageBitmap = ImageBitmap.imageResource( LocalContext.current.resources, R.drawable.landscape5 - ).asAndroidBitmap().copy(Bitmap.Config.ARGB_8888, true).asImageBitmap() + ) + + // mutable bitmap is required to draw to Canvas(bitmap) + val mutableImageBitmap = remember(sourceImageBitmap) { + sourceImageBitmap.asAndroidBitmap() + .copy(Bitmap.Config.ARGB_8888, true) + .asImageBitmap() + } - val aspectRatio = imageBitmap.width / imageBitmap.height.toFloat() + val aspectRatio = mutableImageBitmap.width / mutableImageBitmap.height.toFloat() EraseBitmapSample( - imageBitmap = imageBitmap, + imageBitmap = mutableImageBitmap, modifier = Modifier .fillMaxWidth() .aspectRatio(aspectRatio) @@ -91,7 +102,7 @@ fun EraseBitmapSample(imageBitmap: ImageBitmap, modifier: Modifier) { var matchPercent by remember { - mutableStateOf(100f) + mutableFloatStateOf(100f) } BoxWithConstraints(modifier) { @@ -110,8 +121,13 @@ fun EraseBitmapSample(imageBitmap: ImageBitmap, modifier: Modifier) { val imageHeight = constraints.maxHeight - val drawImageBitmap = remember { - Bitmap.createScaledBitmap(imageBitmap.asAndroidBitmap(), imageWidth, imageHeight, false) + val drawImageBitmap: ImageBitmap = remember(imageBitmap) { + Bitmap.createScaledBitmap( + imageBitmap.asAndroidBitmap(), + imageWidth, + imageHeight, + false + ) .asImageBitmap() } @@ -166,6 +182,7 @@ fun EraseBitmapSample(imageBitmap: ImageBitmap, modifier: Modifier) { .onEach { matchPercent = it } + .flowOn(Dispatchers.Default) .launchIn(this) } @@ -181,6 +198,7 @@ fun EraseBitmapSample(imageBitmap: ImageBitmap, modifier: Modifier) { previousPosition = currentPosition } + MotionEvent.Move -> { erasePath.quadraticTo( @@ -198,6 +216,7 @@ fun EraseBitmapSample(imageBitmap: ImageBitmap, modifier: Modifier) { previousPosition = currentPosition motionEvent = MotionEvent.Idle } + else -> Unit } @@ -282,7 +301,6 @@ fun EraseBitmapSample(imageBitmap: ImageBitmap, modifier: Modifier) { color = androidx.compose.ui.graphics.Color.Red, fontSize = 22.sp ) - } @Synchronized @@ -298,13 +316,18 @@ private fun compareBitmaps( val size = imageWidth * imageHeight val erasedBitmapPixels = IntArray(size) - erasedBitmap.readPixels( - buffer = erasedBitmapPixels, - startX = 0, - startY = 0, - width = imageWidth, - height = imageHeight - ) + val totalTime = measureTime { + erasedBitmap.readPixels( + buffer = erasedBitmapPixels, + startX = 0, + startY = 0, + width = imageWidth, + height = imageHeight + ) + } + + println("Thread: ${Thread.currentThread().name},totalTime: $totalTime") + erasedBitmapPixels.forEachIndexed { index, pixel: Int -> if (originalPixels[index] == pixel) { diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_30LinearInterpolation.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_30LinearInterpolation.kt index c199c999..f7cdf524 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_30LinearInterpolation.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_30LinearInterpolation.kt @@ -461,14 +461,6 @@ fun SnackCard( } } -@Preview -@Composable -fun Test() { - val scaledValue = scale(0f, 1f, 0.5f, 100f, 200f) - - println("scaledValue: $scaledValue") -} - // Scale x1 from a1..b1 range to a2..b2 range internal fun scale(a1: Float, b1: Float, x1: Float, a2: Float, b2: Float) = lerp(a2, b2, calcFraction(a1, b1, x1)) diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_33StoppableInfiniteAnimation.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_33StoppableInfiniteAnimation.kt index d759c692..aae36f46 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_33StoppableInfiniteAnimation.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_33StoppableInfiniteAnimation.kt @@ -14,14 +14,21 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ScreenRotation import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -31,6 +38,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -45,7 +53,194 @@ fun Tutorial6_33Screen() { @Composable private fun TutorialContent() { - StoppableInfiniteAnimationSample() + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()) + ) { + InfiniteRotationInterruptionSample() + Spacer(Modifier.height(16.dp)) + StoppableInfiniteAnimationSample() + } +} + +@Preview +@Composable +fun AnimatableIntteruptionSample() { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + val animatable = remember { + Animatable(0f) + } + + val coroutineScope = rememberCoroutineScope() + + Canvas( + modifier = Modifier.size(100.dp).rotate(animatable.value) + .border(2.dp, Color.Green, CircleShape) + ) { + + drawLine( + start = center, + end = Offset(center.x, 0f), + color = Color.Red, + strokeWidth = 4.dp.toPx() + ) + } + + Text( + "animatable2: ${animatable.value.toInt()}\n" + ) + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + coroutineScope.launch { + try { + val result = animatable.animateTo( + targetValue = 360f, + animationSpec = tween(durationMillis = 4000, easing = LinearEasing) + ) + + println("Result: $result") + } catch (e: CancellationException) { + println("Exception: ${e.message}") + } + + } + } + ) { + Text("Animate to 360") + } + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + coroutineScope.launch { + animatable.snapTo(350f) + } + } + ) { + Text("Snap to 350") + } + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + coroutineScope.launch { + animatable.snapTo(0f) + } + } + ) { + Text("Snap to 0") + } + } +} + +@Preview +@Composable +fun InfiniteRotationInterruptionSample() { + + var animationDuration by remember { mutableIntStateOf(2000) } + + val animatable = remember { + Animatable(0f) + } + + val animatable2 = remember { + Animatable(0f) + } + + LaunchedEffect(animationDuration) { + while (isActive) { + try { + animatable.animateTo( + targetValue = 360f, + animationSpec = tween(animationDuration, easing = LinearEasing) + ) + } catch (e: CancellationException) { + println("Animation canceled with: $e") + } + + if (animatable.value>= 360f) { + animatable.snapTo(targetValue = 0f) + } + } + } + + LaunchedEffect(animationDuration) { + while (isActive) { + val currentValue = animatable2.value + try { + animatable2.animateTo( + targetValue = 360f, + animationSpec = tween((animationDuration * (360f - currentValue) / 360f).toInt(), easing = LinearEasing) + ) + + } catch (e: CancellationException) { + println("Animation2 canceled with: $e") + } + + if (animatable2.value>= 360f) { + animatable2.snapTo(targetValue = 0f) + } + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + Text("Default Animatable Behaviour", fontSize = 24.sp) + Canvas( + modifier = Modifier.size(100.dp).rotate(animatable.value) + .border(2.dp, Color.Green, CircleShape) + ) { + + drawLine( + start = center, + end = Offset(center.x, 0f), + color = Color.Red, + strokeWidth = 4.dp.toPx() + ) + } + + Text( + "animatable2: ${animatable.value.toInt()}\n" + + "animationDuration: $animationDuration" + ) + + Text("Adjust duration after Interruption", fontSize = 24.sp) + + Canvas( + modifier = Modifier.size(100.dp).rotate(animatable2.value) + .border(2.dp, Color.Green, CircleShape) + ) { + + drawLine( + start = center, + end = Offset(center.x, 0f), + color = Color.Red, + strokeWidth = 4.dp.toPx() + ) + } + + Text( + "animatable: ${animatable2.value.toInt()}\n" + + "animationDuration: $animationDuration" + ) + + Button( + modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(), + onClick = { + animationDuration += 4000 + } + ) { + Text("Change duration") + } + } } @Preview @@ -64,6 +259,9 @@ private fun StoppableInfiniteAnimationSample() { modifier = Modifier.fillMaxSize() ) { + + Text("Infinite Stoppable Animatable", fontSize = 24.sp) + Row( modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically, @@ -72,8 +270,9 @@ private fun StoppableInfiniteAnimationSample() { Text("Text1") Column { Canvas( - modifier = Modifier.size(100.dp).rotate(rotateAnimationState.angle) - .border(2.dp, Color.Green) + modifier = Modifier.size(100.dp) + .rotate(rotateAnimationState.angle) + .border(2.dp, Color.Green, CircleShape) ) { drawLine( @@ -134,7 +333,6 @@ class RotateAnimationState( get() = animatable.value private val animatable = Animatable(0f) - private val durationPerAngle = duration / 360f var rotationStatus: RotationStatus = RotationStatus.Idle @@ -167,10 +365,10 @@ class RotateAnimationState( coroutineScope.launch { rotationStatus = RotationStatus.Stopping val currentValue = animatable.value + val durationPerAngle = duration / 360f // Duration depends on how far current angle is to 360f // total duration is duration per angle multiplied with total angles to rotate val durationToZero = (durationPerAngle * (360 - currentValue)).toInt() - animatable.snapTo(currentValue) animatable.animateTo( targetValue = 360f, tween( diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_36InnerShadow.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_36InnerShadow.kt new file mode 100644 index 00000000..4ba498fc --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_36InnerShadow.kt @@ -0,0 +1,143 @@ +package com.smarttoolfactory.tutorial1_1basics.chapter6_graphics + +import android.graphics.BlurMaskFilter +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Slider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + + +@Preview +@Composable +fun InnerShadowSample() { + + Column( + modifier = Modifier.fillMaxSize() + .background(Color(0xff00796B)) + .padding(16.dp) + ) { + + var blurRadiusWhite by remember { + mutableFloatStateOf(5f) + } + + var blurRadiusBlack by remember { + mutableFloatStateOf(4f) + } + + Text("Blur white: $blurRadiusWhite") + + + Slider( + value = blurRadiusWhite, + onValueChange = { + blurRadiusWhite = it + }, + valueRange = 1f..20f + ) + + Text("Blur black: $blurRadiusBlack") + Slider( + value = blurRadiusBlack, + onValueChange = { + blurRadiusBlack = it + }, + valueRange = 1f..20f + ) + + + Row( + modifier = Modifier + .innerShadow( + shape = RoundedCornerShape(16.dp), + color = Color.White.copy(.8f), + x = (-2).dp, + y = (-2).dp, + blurRadius = blurRadiusWhite.dp + ) + .innerShadow( + shape = RoundedCornerShape(16.dp), + color = Color.Black, + x = 2.dp, + y = 2.dp, + blurRadius = (blurRadiusBlack).dp + ) + .background(Color(0xff009688), RoundedCornerShape(16.dp)) + .padding(16.dp) + ) { + Text( + text = "Hello World", + fontSize = 30.sp, + + ) + } + } +} + +fun Modifier.innerShadow( + shape: Shape, + color: Color = Color.Black, + x: Dp = 2.dp, + y: Dp = 2.dp, + blurRadius: Dp = 4.dp, +) = composed { + val paint = remember { + Paint() + } + + println("Paint: $paint") + + drawWithContent { + drawContent() + + val outline = shape.createOutline(size, layoutDirection, this) + + paint.color = color + + with(drawContext.canvas) { + saveLayer(size.toRect(), paint) + + drawOutline(outline, paint) + + val frameworkPaint = paint.asFrameworkPaint() + + frameworkPaint.apply { + xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT) + maskFilter = + BlurMaskFilter(blurRadius.toPx(), BlurMaskFilter.Blur.NORMAL) + } + + paint.color = Color.Black + + translate(x.toPx(), y.toPx()) + drawOutline(outline, paint) + + frameworkPaint.xfermode = null + frameworkPaint.maskFilter = null + restore() + } + } +} \ No newline at end of file diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_37CombineShapes.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_37CombineShapes.kt new file mode 100644 index 00000000..465475c8 --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_37CombineShapes.kt @@ -0,0 +1,238 @@ +package com.smarttoolfactory.tutorial1_1basics.chapter6_graphics + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CutCornerShape +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathOperation +import androidx.compose.ui.graphics.addOutline +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp + +@Preview +@Composable +fun ShapeCombineTest() { + + val density = LocalDensity.current + + + Column( + modifier = Modifier.fillMaxSize() + .padding(16.dp) + ) { + + OutlinedCard( + shape = FusedShape(density), + + ) { + Box( + modifier = Modifier.size(200.dp, 100.dp), + contentAlignment = Alignment.Center + ) { + Text("M3 Card") + } + } + + Spacer(Modifier.height(16.dp)) + + ElevatedCard( + shape = FusedShape( + density = density, + topStart = CornerShape.CutCorner(16.dp), + topEnd = CornerShape.CutCorner(16.dp), + bottomStart = CornerShape.RoundedCorner(16.dp), + bottomEnd = CornerShape.RoundedCorner(16.dp) + ) + ) { + Box( + modifier = Modifier.size(200.dp, 100.dp), + contentAlignment = Alignment.Center + ) { + Text("M3 Card") + } + } + + Spacer(Modifier.height(16.dp)) + + ElevatedCard( + shape = FusedShape( + density = density, + topStart = CornerShape.RoundedCorner(16.dp), + topEnd = CornerShape.CutCorner(16.dp), + bottomStart = CornerShape.RoundedCorner(16.dp), + bottomEnd = CornerShape.CutCorner(16.dp) + ), + ) { + Box( + modifier = Modifier.size(200.dp, 100.dp), + contentAlignment = Alignment.Center + ) { + Text("M3 Card") + } + } + + Spacer(Modifier.height(16.dp)) + + Button( + modifier = Modifier.fillMaxWidth(), + shape = FusedShape(density), + onClick = { + + } + ) { + Text("M3 Button") + } + } +} + +sealed class CornerShape(val radius: Dp) { + class CutCorner(cornerRadius: Dp) : CornerShape(cornerRadius) + class RoundedCorner(cornerRadius: Dp) : CornerShape(cornerRadius) +} + +private fun FusedShape( + density: Density, + topStart: CornerShape = CornerShape.RoundedCorner(16.dp), + topEnd: CornerShape = CornerShape.CutCorner(16.dp), + bottomStart: CornerShape = CornerShape.CutCorner(16.dp), + bottomEnd: CornerShape = CornerShape.RoundedCorner(16.dp), +) = + GenericShape { size: Size, layoutDirection: LayoutDirection -> + + val cutCornerTopStart = + if (topStart is CornerShape.CutCorner) topStart.radius else 0.dp + val cutCornerTopEnd = + if (topEnd is CornerShape.CutCorner) topEnd.radius else 0.dp + val cutCornerBottomStart = + if (bottomStart is CornerShape.CutCorner) bottomStart.radius else 0.dp + val cutCornerBottomEnd = + if (bottomEnd is CornerShape.CutCorner) bottomEnd.radius else 0.dp + + val roundedCornerTopStart = + if (topStart is CornerShape.RoundedCorner) topStart.radius else 0.dp + val roundedCornerTopEnd = + if (topEnd is CornerShape.RoundedCorner) topEnd.radius else 0.dp + val roundedCornerBottomStart = + if (bottomStart is CornerShape.RoundedCorner) bottomStart.radius else 0.dp + val roundedCornerBottomEnd = + if (bottomEnd is CornerShape.RoundedCorner) bottomEnd.radius else 0.dp + + val cutoutOutline = + CutCornerShape( + topStart = cutCornerTopStart, + topEnd = cutCornerTopEnd, + bottomStart = cutCornerBottomStart, + bottomEnd = cutCornerBottomEnd + ) + .createOutline( + size, + layoutDirection, + density + ) + + val roundedCornerOutline = + RoundedCornerShape( + topStart = roundedCornerTopStart, + topEnd = roundedCornerTopEnd, + bottomStart = roundedCornerBottomStart, + bottomEnd = roundedCornerBottomEnd + ).createOutline( + size, + layoutDirection, + density + ) + + val path1 = Path().apply { + addOutline(cutoutOutline) + } + + val path2 = Path().apply { + addOutline(roundedCornerOutline) + } + + addPath( + Path.combine( + operation = PathOperation.Intersect, + path1 = path1, + path2 = path2 + ) + ) + } + + +@Composable +private fun ShapeFusionBox() { + + + val density = LocalDensity.current + + Box( + modifier = Modifier + .size(200.dp, 100.dp) + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + } + .drawWithCache { + val cutoutOutline = + CutCornerShape(bottomStart = 16.dp, topEnd = 16.dp).createOutline( + size, + layoutDirection, + density + ) + + val roundedCornerOutline = + RoundedCornerShape(topStart = 16.dp, bottomEnd = 16.dp).createOutline( + size, + layoutDirection, + density + ) + + onDrawBehind { + + // You can draw with painter or ImageBitmap here + drawOutline( + outline = roundedCornerOutline, + color = Color.Blue + ) + + // You can draw with painter or ImageBitmap here + drawOutline( + outline = cutoutOutline, + color = Color.Blue, + blendMode = BlendMode.Xor + ) + + drawRect( + color = Color.Red, + blendMode = BlendMode.SrcOut + ) + } + } + ) +} diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_38ArcSlider.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_38ArcSlider.kt new file mode 100644 index 00000000..10b09699 --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_38ArcSlider.kt @@ -0,0 +1,382 @@ +package com.smarttoolfactory.tutorial1_1basics.chapter6_graphics + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastFirst +import com.smarttoolfactory.tutorial1_1basics.ui.Blue400 +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin + + +@Preview +@Composable +fun DrawEllipticArc() { + Column( + modifier = Modifier.fillMaxSize() + ) { + + Canvas( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + .aspectRatio(3f) + .border(1.dp, Color.Red) + ) { + + drawArc( + color = Color.Green, + startAngle = 180f, + sweepAngle = 180f, + size = Size(size.width, size.height * 2), + useCenter = true + ) + + val strokeWidthPx = 12.dp.toPx() + + translate( + top = strokeWidthPx / 2, + left = strokeWidthPx / 2 + ) { + drawArc( + color = Blue400, + size = Size(size.width - strokeWidthPx, (size.height - strokeWidthPx / 2) * 2), + startAngle = 180f, + sweepAngle = 180f, + style = Stroke(strokeWidthPx), + useCenter = false + ) + } + + } + } +} + +@Preview +@Composable +fun ArcSliderSample() { + Column( + modifier = Modifier + .fillMaxSize().padding(vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + var value by remember { + mutableFloatStateOf(0f) + } + + val density = LocalDensity.current + val width = with(density) { + 1000f.toDp() + } + + Column( + modifier = Modifier.border(2.dp, Color.Black).width(width).fillMaxHeight() + ) { + Box { + ArcSlider( + modifier = Modifier + .width(width) + .aspectRatio(2f), + value = value + ) { + value = it + } + + Text( + text = "Value: ${(value * 100).toInt()}", + fontSize = 28.sp, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 32.dp) + ) + } + } + } +} + +private const val thumbId = "thumb" +private const val trackId = "track" + +@Composable +fun ArcSlider( + modifier: Modifier = Modifier, + thumb: @Composable () -> Unit = { + Thumb() + }, + value: Float, + onValueChange: (Float) -> Unit, +) { + + val density = LocalDensity.current + val strokeWidth = with(density) { + 40f.toDp() + } + + var angle by remember { + mutableFloatStateOf(value * 180f) + }.apply { this.floatValue = value * 180f } + + + var thumbPosition by remember { + mutableStateOf(Offset.Unspecified) + } + + var thumbSize by remember { + mutableStateOf(IntSize.Zero) + } + + var isTouched by remember { + mutableStateOf(false) + } + + var trackRect by remember { + mutableStateOf(Rect.Zero) + } + + val measurePolicy = remember { + MeasurePolicy { measurables, constraints -> + val strokeWidthPx = strokeWidth.roundToPx() + + val thumbPlaceable = + measurables.fastFirst { it.layoutId == thumbId }.measure( + constraints.copy( + minWidth = 0, + minHeight = 0 + ) + ) + + val thumbWidth = thumbPlaceable.width + val thumbHeight = thumbPlaceable.height + + // TODO check for infinite constraints + // Available width is minimum of max width - thumb width versus max height - half height of thumb at the bottom + // and of half thumb width - stroke width/2 + val availableWidth = (constraints.maxWidth - thumbWidth) / 2 + val availableHeight = (constraints.maxHeight - thumbHeight + strokeWidthPx / 2) + + val trackMeasurementWidth = availableWidth.coerceAtMost(availableHeight) + + val trackPlaceable = measurables.fastFirst { it.layoutId == trackId }.measure( + Constraints.fixed(trackMeasurementWidth * 2, trackMeasurementWidth) + ) + + val sliderWidth = trackPlaceable.width + val sliderHeight = trackPlaceable.height + + // radius calculated at the center of stroke width + val radius = sliderWidth / 2 - strokeWidthPx / 2 + + // Pivot points in bottom center of track for rotating thumb + val trackPivotX = constraints.maxWidth / 2 + val trackPivotY = sliderHeight + (thumbHeight - strokeWidthPx) / 2 + + val thumbX = trackPivotX + (-radius) * cos(angle.degreeToRadian) + val thumbY = trackPivotY + (-radius) * sin(angle.degreeToRadian) + thumbPosition = Offset(thumbX, thumbY) + + val layoutWidth = constraints.maxWidth + val layoutHeight = constraints.maxHeight + + layout(layoutWidth, layoutHeight) { + trackPlaceable.placeRelative( + x = (layoutWidth - sliderWidth) / 2, + y = (thumbHeight - strokeWidthPx) / 2 + ) + + if (thumbPosition != Offset.Unspecified) { + thumbPlaceable.placeRelative( + x = (thumbPosition.x - thumbWidth / 2).toInt(), + y = (thumbPosition.y - thumbHeight / 2).toInt() + ) + } + } + } + } + + val dragModifier = Modifier.pointerInput(Unit) { + detectDragGestures( + onDragStart = { offset -> + if (thumbPosition != Offset.Unspecified && thumbSize != IntSize.Zero) { + val radius = thumbSize.width / 2 + isTouched = offset.minus(thumbPosition).getDistanceSquared() < radius * radius + } + }, + onDrag = { change: PointerInputChange, _: Offset -> + if (isTouched) { + val touchPosition: Offset = change.position + + val centerX = trackRect.bottomCenter.x + val centerY = trackRect.bottomCenter.y + + angle = + ( + atan2( + x = touchPosition.x.coerceIn(0f, size.width.toFloat()) - centerX, + y = touchPosition.y.coerceIn(0f, size.height.toFloat()) - centerY + ) + ) * 180 / Math.PI.toFloat() + + + // If angle is in top half add 180 degrees since + // atan2 returns angle between -PI and PI + if (angle < 0) { + angle += 180f + } else if (angle < 90) { + // If touch is in bottom end set to 180f because it's out of slider bounds + angle = 180f + } else { + // If touch is in bottom start set to 0f because it's out of slider bounds + angle = 0f + } + + onValueChange(scale(0f, 180f, angle, 0f, 1f)) + } + }, + onDragEnd = { + isTouched = false + }, + onDragCancel = { + isTouched = false + } + ) + } + Layout( + modifier = modifier.then(dragModifier) + // These 2 modifiers are for debugging for container bounds and track bounds +// .border(2.dp, Color.Red) +// .drawWithContent { +// drawContent() +// drawRect( +// color = Color.Cyan, +// topLeft = trackRect.topLeft, +// size = trackRect.size, +// style = Stroke(4.dp.toPx(), pathEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 20f))) +// ) +// } + , + content = { + Box( + modifier = Modifier + .layoutId(trackId) + .onPlaced { + trackRect = it.boundsInParent() + } + ) { + Canvas(modifier = Modifier.matchParentSize()) { + val strokeWidthPx = strokeWidth.toPx() + + translate( + left = strokeWidthPx / 2, + top = strokeWidthPx / 2 + ) { + drawArc( + color = Blue400.copy(alpha = .25f), + size = Size(size.width - strokeWidthPx, (size.height - strokeWidthPx / 2) * 2), + startAngle = 180f, + sweepAngle = 180f, + style = Stroke( + strokeWidthPx, + cap = StrokeCap.Round + ), + useCenter = false + ) + + drawArc( + color = Blue400, + size = Size(size.width - strokeWidthPx, (size.height - strokeWidthPx / 2) * 2), + startAngle = 180f, + sweepAngle = scale(0f, 1f, value, 0f, 180f), + style = Stroke( + strokeWidthPx, + cap = StrokeCap.Round + ), + useCenter = false + ) + } + + // line for debugging angle inside canvas +// val lineStrokeWidth = 1.dp.toPx() +// +// rotate( +// degrees = angle, +// pivot = Offset(center.x, size.height) +// ) { +// drawLine( +// color = Color.Black, +// start = Offset(center.x, size.height), +// end = Offset(0f, size.height), +// strokeWidth = lineStrokeWidth +// ) +// } + } + } + + Box(modifier = Modifier.layoutId(thumbId) + .onSizeChanged { + thumbSize = it + } + ) { + thumb() + } + }, + measurePolicy = measurePolicy + ) +} + +@Composable +private fun Thumb() { + + val density = LocalDensity.current + val size = with(density) { + 100f.toDp() + } + Box( + modifier = Modifier + .border(8.dp, Color.White, CircleShape) + .size(size) + .shadow(4.dp, CircleShape) + .background(Blue400, CircleShape) + + ) +} \ No newline at end of file diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_39_1GraphicsLayer1.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_39_1GraphicsLayer1.kt new file mode 100644 index 00000000..29b7be04 --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_39_1GraphicsLayer1.kt @@ -0,0 +1,587 @@ +package com.smarttoolfactory.tutorial1_1basics.chapter6_graphics + +import android.graphics.Bitmap +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.Slider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.smarttoolfactory.tutorial1_1basics.R +import com.smarttoolfactory.tutorial1_1basics.chapter2_material_widgets.CheckBoxWithTextRippleFullRow +import com.smarttoolfactory.tutorial1_1basics.ui.backgroundColor +import kotlinx.coroutines.launch +import kotlin.random.Random + +@RequiresApi(Build.VERSION_CODES.S) +@Preview +@Composable +private fun GraphicsLayerSample() { + val graphicsLayer = rememberGraphicsLayer() + + Column { + Canvas( + Modifier.fillMaxWidth() + .border(2.dp, Color.Red) + .clipToBounds() + .aspectRatio(4 / 3f) + ) { + drawLayer(graphicsLayer) + } + + Spacer(Modifier.height(16.dp)) + + LazyColumn( + modifier = Modifier + .border(2.dp, Color.Green) + .background(backgroundColor) + .fillMaxSize() + .drawWithContent { + // 🔥Without this LazyColumn does not draw its content + drawContent() + graphicsLayer.record { + this@drawWithContent.drawContent() + } + }, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(100) { + + Box( + modifier = Modifier.fillMaxWidth() + .background(Color.White, RoundedCornerShape(16.dp)).padding(16.dp) + ) { + androidx.compose.material3.Text("Row $it", fontSize = 22.sp) + } + } + } + } +} + +@Preview +@Composable +private fun GraphicsLayerToImageBitmapSample() { + + val coroutineScope = rememberCoroutineScope() + val graphicsLayer = rememberGraphicsLayer() + + var imageBitmap by remember { + mutableStateOf(null) + } + + var touchPosition by remember { + mutableStateOf(Offset.Unspecified) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + + Image( + painter = painterResource(R.drawable.avatar_2_raster), + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures { offset: Offset -> + touchPosition = offset + } + } + .drawWithContent { + drawContent() + graphicsLayer.record { + this@drawWithContent.drawContent() + } + } + .drawWithContent { + drawContent() + if (touchPosition != Offset.Unspecified) { + drawCircle( + color = Color.Blue, + radius = size.width * .1f, + center = touchPosition, + style = Stroke( + 8.dp.toPx(), pathEffect = PathEffect.dashPathEffect( + floatArrayOf(20f, 20f) + ) + ) + ) + } + } + .shadow(4.dp, RoundedCornerShape(16.dp)) + .fillMaxWidth() + .aspectRatio(1f), + contentDescription = null + ) + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + coroutineScope.launch { + imageBitmap = graphicsLayer.toImageBitmap() + } + } + ) { + Text("Convert graphicsLayer to ImageBitmap") + } + + Text(text = "Screenshot of Composable", fontSize = 22.sp) + imageBitmap?.let { + Image( + bitmap = it, + modifier = Modifier + .fillMaxWidth(.7f) + .aspectRatio(1f), + contentDescription = null + ) + } + } +} + +data class TestParticle( + val initialCenter: Offset, + val initialSize: Size, + val initialAlpha: Float, + val color: Color, + val scale: Float = 1f, + val decayFactor: Int, +) { + + val initialRadius: Float = initialSize.width.coerceAtMost(initialSize.height) / 2f + var radius: Float = scale * initialRadius + + var alpha: Float = initialAlpha + + val active: Boolean + get() = radius> 0 && alpha> 0 + + var center: Offset = initialCenter + + val initialRect: Rect + get() = Rect( + offset = Offset( + x = initialCenter.x - initialRadius, + y = initialCenter.y - initialRadius + ), + size = initialSize + ) + + val rect: Rect + get() = Rect( + offset = Offset(center.x - radius, center.y - radius), + size = Size(radius * 2, radius * 2) + ) +} + +@Preview +@Composable +fun GraphicsLayerToParticles() { + val coroutineScope = rememberCoroutineScope() + val graphicsLayer = rememberGraphicsLayer() + + val particleList = remember { + mutableStateListOf() + } + + var particleSize by remember { + mutableFloatStateOf(10f) + } + + val animatable = remember { + Animatable(1f) + } + + var duration by remember { + mutableFloatStateOf(3000f) + } + + var animateSize by remember { + mutableStateOf(true) + } + + var animateAlpha by remember { + mutableStateOf(false) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + + val density = LocalDensity.current + val widthDp = with(density) { + 500.toDp() + } + + Image( + painter = painterResource(R.drawable.avatar_2_raster), + modifier = Modifier + .drawWithContent { + drawContent() + graphicsLayer.record { + this@drawWithContent.drawContent() + } + } + .size(widthDp), + contentDescription = null + ) + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + coroutineScope.launch { + animatable.snapTo(1f) + graphicsLayer.toImageBitmap().let { + particleList.clear() + particleList.addAll( + createParticles( + imageBitmap = it.asAndroidBitmap() + .copy(Bitmap.Config.ARGB_8888, false) + .asImageBitmap(), + particleSize = particleSize.toInt() + ) + ) + } + } + } + ) { + Text("Convert graphicsLayer to particles") + } + + if (particleList.isEmpty().not()) { + + Canvas( + modifier = Modifier + .border(2.dp, if (animatable.isRunning) Color.Green else Color.Red) + .clickable { + coroutineScope.launch { + animatable.snapTo(0f) + animatable.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = duration.toInt(), + easing = LinearEasing + ), + block = { + + val progress = this.value + + particleList.forEachIndexed { _, particle -> + + if (particle.active) { + val posX = particle.center.x + val posY = particle.center.y + + val newX = + posX + 3f * Random.nextFloat() + val newY = posY - 15f * Random.nextFloat() + + particle.center = + Offset(newX, newY) + + val particleDecayFactor = particle.decayFactor + + val decayFactor = + if (progress < .80f) particleDecayFactor + else if (progress < .85f) particleDecayFactor + 1 + else if (progress < .9f) particleDecayFactor + 4 + else if (progress < .97) + particleDecayFactor + .coerceAtLeast(5) + 1 + else 1 + + if (animateSize) { + val radius = particle.radius + val newRadius = + radius - progress * decayFactor * particle.initialRadius / 100f + + particle.radius = newRadius.coerceAtLeast(0f) + } + if (animateAlpha) { + particle.alpha -= (progress) * Random.nextFloat() / 20f + } + + if (progress == 1f) { + particle.alpha = 0f + } + + } + } + + val aliveParticle = particleList.filter { it.active }.size + + println("alive particle size: $aliveParticle, progress: $progress") + } + ) + } + } + .size(widthDp) + ) { + + // TODO Remove this and invalidate Canvas more gracefully + drawCircle(color = Color.Transparent, radius = animatable.value) + + particleList.forEach { particle: TestParticle -> + + if (particle.active) { + // For debugging borders of particles +// val rect = particle.rect +// drawRect( +// color = Color.Red, +// topLeft = rect.topLeft, +// size = rect.size, +// style = Stroke(1.dp.toPx()) +// ) + + drawCircle( + color = particle.color.copy(alpha = particle.alpha), + radius = particle.radius, + center = particle.center, + ) + } + } + } + + Text(text = "Particle size: ${particleSize.toInt()}px", fontSize = 22.sp) + + Slider( + value = particleSize, + onValueChange = { + particleSize = it + }, + valueRange = 2f..100f + ) + + Text("Duration: ${duration.toInt()}", fontSize = 22.sp) + Slider( + value = duration, + onValueChange = { + duration = it + }, + valueRange = 1000f..7000f + ) + + CheckBoxWithTextRippleFullRow( + label = "Animate size", + state = animateSize, + onStateChange = { + animateSize = it + } + ) + + CheckBoxWithTextRippleFullRow( + label = "Animate alpha", + state = animateAlpha, + onStateChange = { + animateAlpha = it + } + ) + } + } +} + +fun createParticles(imageBitmap: ImageBitmap, particleSize: Int): List { + val particleList = mutableStateListOf() + + val width = imageBitmap.width + val height = imageBitmap.height + + val bitmap: Bitmap = imageBitmap.asAndroidBitmap() + + val columnCount = width / particleSize + val rowCount = height / particleSize + + println( + "Bitmap width: $width, height: $height, " + + "columnCount: $columnCount, rowCount: $rowCount" + ) + + val particleRadius = particleSize / 2 + + // divide image into squares based on particle size + // 110x100x image is divided into 10x10 squares + + for (posX in 0 until width step particleSize) { + for (posY in 0 until height step particleSize) { + + // TODO Assign these params + val scale = Random.nextInt(95, 110) / 100f +// val scale = 1f + val decayFactor = Random.nextInt(10) +// val alpha = Random.nextFloat().coerceAtLeast(.5f) + val alpha = 1f + + // Get pixel at center of this pixel rectangle + // If last pixel is out of image get it from end of the width or height + // 🔥x must be < bitmap.width() and y must be < bitmap.height() + val pixelCenterX = (posX + particleRadius).coerceAtMost(width - 1) + val pixelCenterY = (posY + particleRadius).coerceAtMost(height - 1) + + val pixel: Int = bitmap.getPixel(pixelCenterX, pixelCenterY) + val color = Color(pixel) + + if (color != Color.Unspecified) { + val size = particleSize * 1f + + particleList.add( + TestParticle( + initialCenter = Offset( + x = pixelCenterX.toFloat(), + y = pixelCenterY.toFloat() + ), + initialSize = Size(size, size), + initialAlpha = alpha, + color = color, + scale = scale, + decayFactor = decayFactor + ) + ) + } else { + println("Not adding transparent pixel") + } + } + } + + return particleList +} + +@Preview +@Composable +fun InversePixelsSample() { + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + + Text( + text = "Original Colors", + fontSize = 34.sp, + modifier = Modifier.padding(vertical = 16.dp) + ) + + Row { + Image( + modifier = Modifier.weight(1f).aspectRatio(1f), + painter = painterResource(R.drawable.avatar_1_raster), + contentDescription = null + ) + Image( + modifier = Modifier.weight(1f).aspectRatio(1f), + painter = painterResource(R.drawable.avatar_2_raster), + contentDescription = null + ) + } + + Text( + text = "Color Filtered", + fontSize = 34.sp, + modifier = Modifier.padding(vertical = 16.dp) + ) + + Row( + modifier = Modifier + .drawWithCache { + val graphicsLayer = obtainGraphicsLayer() + + val invertedColorMatrix = floatArrayOf( + -1f, 0f, 0f, 0f, 255f, + 0f, -1f, 0f, 0f, 255f, + 0f, 0f, -1f, 0f, 255f, + 0f, 0f, 0f, 1f, 0f + ) + + val sepiaMatrix = floatArrayOf( + 1f, 0f, 0f, 0f, 0f, + 0f, 1f, 0f, 0f, 0f, + 0f, 0f, 0.85f, 0f, 0f, + 0f, 0f, 0f, 1f, 0f + ) + + graphicsLayer.apply { + record { + drawContent() + } +// blendMode = BlendMode.Difference + // Sepia or inverted Matrices +// colorFilter = ColorFilter.colorMatrix(ColorMatrix(sepiaMatrix)) + // Black-White + colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { + this.setToSaturation(0f) + }) + } + + onDrawWithContent { + drawLayer(graphicsLayer) + } + } + ) { + Image( + modifier = Modifier.weight(1f).aspectRatio(1f), + painter = painterResource(R.drawable.avatar_1_raster), + contentDescription = null + ) + Image( + modifier = Modifier.weight(1f).aspectRatio(1f), + painter = painterResource(R.drawable.avatar_2_raster), + contentDescription = null + ) + } + + } +} \ No newline at end of file diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_40_1RenderScript1.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_40_1RenderScript1.kt new file mode 100644 index 00000000..34fdf620 --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_40_1RenderScript1.kt @@ -0,0 +1,132 @@ +@file:Suppress("DEPRECATION") + +package com.smarttoolfactory.tutorial1_1basics.chapter6_graphics + +import android.content.Context +import android.graphics.Bitmap +import android.renderscript.Allocation +import android.renderscript.Element +import android.renderscript.RenderScript +import android.renderscript.ScriptIntrinsicBlur +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.imageResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.smarttoolfactory.tutorial1_1basics.R +import com.smarttoolfactory.tutorial1_1basics.ui.components.StyleableTutorialText +import com.smarttoolfactory.tutorial1_1basics.ui.components.TutorialHeader +import com.smarttoolfactory.tutorial1_1basics.ui.components.TutorialText2 +import kotlin.math.roundToInt + + +@Preview +@Composable +fun Tutorial6_40Screen1() { + TutorialContent() +} + + +@Composable +private fun TutorialContent() { + Column(modifier = Modifier.padding(8.dp)) { + TutorialHeader(text = "RenderScript") + StyleableTutorialText( + text = "Blue Bitmap using **RenderScript**", + bullets = false + ) + RenderScriptBlursample() + } +} + +@Preview +@Composable +private fun RenderScriptBlursample() { + + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()) + ) { + + val imageBitmap = ImageBitmap.imageResource(R.drawable.landscape10) + + var blurRadius by remember { + mutableFloatStateOf(10f) + } + + val context = LocalContext.current + + val blurredBitmap by remember(imageBitmap, blurRadius) { + mutableStateOf(blurBitmap(context, imageBitmap.asAndroidBitmap(), blurRadius)) + } + + TutorialText2(text = "Default") + + Image( + modifier = Modifier.fillMaxWidth().aspectRatio(3 / 2f), + bitmap = imageBitmap, + contentDescription = null, + contentScale = ContentScale.FillBounds + ) + + + TutorialText2(text = "Blurred") + Image( + modifier = Modifier.fillMaxWidth().aspectRatio(3 / 2f), + bitmap = blurredBitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.FillBounds + ) + + + Text("Blur radius: ${blurRadius.roundToInt()}") + + Slider( + modifier = Modifier.padding(horizontal = 16.dp), + value = blurRadius, + onValueChange = { + blurRadius = it + }, + valueRange = 0.01f..25f + ) + } +} + +fun blurBitmap(context: Context, bitmap: Bitmap, blurRadius: Float): Bitmap { + + val bitmapToBlur = bitmap.copy(Bitmap.Config.ARGB_8888, true) + + val renderScript = RenderScript.create(context) + val input = Allocation.createFromBitmap(renderScript, bitmapToBlur) + val output = Allocation.createTyped(renderScript, input.type) + + ScriptIntrinsicBlur.create(renderScript, Element.U8_4(renderScript)).apply { + setRadius(blurRadius) + setInput(input) + forEach(output) + } + + output.copyTo(bitmapToBlur) + renderScript.destroy() + + return bitmapToBlur +} diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_4_2DrawWithTouch2.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_4_2DrawWithTouch2.kt index 3ce642d1..33be317e 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_4_2DrawWithTouch2.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_4_2DrawWithTouch2.kt @@ -19,10 +19,10 @@ import androidx.compose.material.IconButton import androidx.compose.material.Slider import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Redo +import androidx.compose.material.icons.automirrored.filled.Undo import androidx.compose.material.icons.filled.Brush -import androidx.compose.material.icons.filled.Redo import androidx.compose.material.icons.filled.TouchApp -import androidx.compose.material.icons.filled.Undo import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -186,12 +186,11 @@ private fun DrawingApp() { MotionEvent.Move -> { if (drawMode != DrawMode.Touch) { - currentPath.quadraticBezierTo( + currentPath.quadraticTo( previousPosition.x, previousPosition.y, (previousPosition.x + currentPosition.x) / 2, (previousPosition.y + currentPosition.y) / 2 - ) } @@ -344,7 +343,7 @@ private fun DrawingApp() { val lastPath = pathsUndone.last().first val lastPathProperty = pathsUndone.last().second - pathsUndone.removeLast() + pathsUndone.removeAt(pathsUndone.lastIndex) paths.add(Pair(lastPath, lastPathProperty)) } }, @@ -433,13 +432,13 @@ private fun DrawingPropertiesMenu( IconButton(onClick = { onUndo() }) { - Icon(Icons.Filled.Undo, contentDescription = null, tint = Color.LightGray) + Icon(Icons.AutoMirrored.Filled.Undo, contentDescription = null, tint = Color.LightGray) } IconButton(onClick = { onRedo() }) { - Icon(Icons.Filled.Redo, contentDescription = null, tint = Color.LightGray) + Icon(Icons.AutoMirrored.Filled.Redo, contentDescription = null, tint = Color.LightGray) } } diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_9_1NeonEffect.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_9_1NeonEffect.kt index bfe8e9c7..ed56be36 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_9_1NeonEffect.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_9_1NeonEffect.kt @@ -187,7 +187,6 @@ private fun NeonDrawingSample() { // This is previous motion event before next touch is saved into this current position var previousPosition by remember { mutableStateOf(Offset.Unspecified) } - val transition: InfiniteTransition = rememberInfiniteTransition() // Infinite phase animation for PathEffect @@ -218,17 +217,18 @@ private fun NeonDrawingSample() { this.color = transparent } + + asFrameworkPaint().setShadowLayer( + 35f * phase, + 0f, + 0f, + color + .copy(alpha = phase) + .toArgb() + ) } } - paint.asFrameworkPaint().setShadowLayer( - 35f * phase, - 0f, - 0f, - color - .copy(alpha = phase) - .toArgb() - ) // Path is what is used for drawing line on Canvas val path = remember { Path() } @@ -263,7 +263,7 @@ private fun NeonDrawingSample() { } MotionEvent.Move -> { - path.quadraticBezierTo( + path.quadraticTo( previousPosition.x, previousPosition.y, (previousPosition.x + currentPosition.x) / 2, @@ -296,4 +296,3 @@ private fun NeonDrawingSample() { } } } - diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/AnimatedVisibilityTransitionStateTest.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/AnimatedVisibilityTransitionStateTest.kt index f358cace..e5510773 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/AnimatedVisibilityTransitionStateTest.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/AnimatedVisibilityTransitionStateTest.kt @@ -1,5 +1,6 @@ package com.smarttoolfactory.tutorial1_1basics.chapter9_animation +import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.Spring @@ -38,6 +39,7 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -118,6 +120,15 @@ fun AnimatedVisibilityCloseTest() { ) } + val context = LocalContext.current + + // Check animation end, need a flag to prevent triggering at the start, skipped if deliberately + LaunchedEffect(key1 = visibleState.currentState, key2 = visibleState.targetState) { + if (visibleState.targetState == visibleState.currentState) { + Toast.makeText(context, "Animation finished", Toast.LENGTH_SHORT).show() + } + } + AnimatedVisibility( visibleState = visibleState, enter = fadeIn( diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/Easing.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/Easing.kt new file mode 100644 index 00000000..31ff7b79 --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/Easing.kt @@ -0,0 +1,255 @@ +package com.smarttoolfactory.tutorial1_1basics.chapter9_animation + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.android.awaitFrame +import kotlinx.coroutines.launch + +@Preview +@Composable +private fun Easingsample() { + + val animatable = remember { + Animatable(0f) + } + + val path = remember { + Path() + } + + val coroutineScope = rememberCoroutineScope() + + var startTime by remember { + mutableLongStateOf(0L) + } + + var totalVelocity by remember { + mutableFloatStateOf(0f) + } + + val density = LocalDensity.current + val sizeDp = with(density) { + 1000f.toDp() + } + + Column( + modifier = Modifier.fillMaxSize().padding(16.dp) + ) { + + Canvas( + modifier = Modifier.size(sizeDp).border(2.dp, Color.Blue) + ) { + + val progress = animatable.value + val width = size.width + val height = size.height + + if (startTime == 0L && animatable.isRunning) { + startTime = System.nanoTime() + } + + val currentTime = (System.nanoTime() - startTime) / 1000_000L + + val x = (width * currentTime / 1000f).coerceAtMost(1000f) + + val y = height * (1 - progress) + + (animatable.velocity as? Float)?.let { + totalVelocity += it + } + + if (path.isEmpty.not()) { + path.lineTo(x, y) + } + + drawPath( + path = path, + color = Color.Red, + style = Stroke(2.dp.toPx()) + ) + } + + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + coroutineScope.launch { + totalVelocity = 0f + animatable.snapTo(0f) + path.reset() + path.moveTo(0f, 0f) + animatable.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = 1000, + easing = LinearOutSlowInEasing + ) + ) + + awaitFrame() + startTime = 0 + } + } + ) { + Text("Start") + } + } +} + +data class PathWithAnimatable(val path: Path, val animatable: Animatable) + +@Preview +@Composable +private fun EasingTest2() { + + val data = remember { + List(5) { + PathWithAnimatable( + Path(), + Animatable(0f) + ) + } + } + + var startTime by remember { + mutableLongStateOf(0L) + } + + val coroutineScope = rememberCoroutineScope() + + Column { + Box(modifier = Modifier.fillMaxWidth().aspectRatio(1f)) { + + data.forEachIndexed { index, data -> + val color = when (index) { + 0 -> Color.Red + 1 -> Color.Blue + 2 -> Color.Green + 3 -> Color.Magenta + else -> Color.Black + } + + EasingTestBox( + modifier = Modifier.padding(16.dp), + animatable = data.animatable, + path = data.path, + color = color, + startTime = startTime + ) + } + } + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + + startTime = System.currentTimeMillis() + data.forEachIndexed { index, data -> + + val animationSpec = when (index) { + 0 -> tween( + durationMillis = 1000, + easing = LinearEasing + ) + + 1 -> { + tween( + durationMillis = 1000, + easing = LinearOutSlowInEasing + ) + } + + 2 -> tween( + durationMillis = 1000, + easing = FastOutSlowInEasing + ) + + 3 -> tween( + durationMillis = 1000, + easing = FastOutSlowInEasing + ) + + else -> spring() + } + coroutineScope.launch { + + val path = data.path + path.reset() + val animatable = data.animatable + + animatable.snapTo(0f) + animatable.animateTo( + targetValue = 1f, + animationSpec = animationSpec + ) + } + } + } + ) { + Text("Start") + } + } + + +} + +@Composable +private fun EasingTestBox( + modifier: Modifier = Modifier, + path: Path, + animatable: Animatable, + startTime: Long, + color: Color, +) { + Canvas( + modifier = modifier.fillMaxWidth().aspectRatio(1f).border(1.dp, Color.Blue) + ) { + val progress = animatable.value + val width = size.width + val height = size.height + + val currentTime = System.currentTimeMillis() - startTime + + if (path.isEmpty) { + path.moveTo(0f, height) + } else { + path.lineTo(width * currentTime / 1000f, height * (1 - progress)) + } + + drawPath( + path = path, + color = color, + style = Stroke(2.dp.toPx()) + ) + } +} diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/HorizontalPagerItemDeleteAnimation.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/HorizontalPagerItemDeleteAnimation.kt new file mode 100644 index 00000000..37d16e3d --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/HorizontalPagerItemDeleteAnimation.kt @@ -0,0 +1,237 @@ +package com.smarttoolfactory.tutorial1_1basics.chapter9_animation + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.layout.layout +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import com.smarttoolfactory.tutorial1_1basics.ui.backgroundColor +import com.smarttoolfactory.tutorial1_1basics.ui.components.TutorialHeader +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import java.util.UUID + + +data class MyData(val id: String = UUID.randomUUID().toString(), val value: Int, val isAlive: Boolean = true) + +class MyViewModel : ViewModel() { + val list = + mutableStateListOf().apply { + repeat(6) { + add( + MyData(value = it) + ) + } + } + + fun updateStatus(index: Int) { + val newItem = list[index].copy(isAlive = false) + list[index] = newItem + println("Update Status: $index") + } + + fun removeItem(index: Int) { + println("🔥 Remove item: $index") + list.removeAt(index) + } +} + +@Preview +@Composable +private fun TutorialContent() { + Column( + modifier = Modifier + .fillMaxSize() + .background(backgroundColor) + ) { + TutorialHeader("Horizontal Pager delete animation") + Spacer(Modifier.height(16.dp)) + PagerRemoveAnimationSample() + + } +} + +@Composable +private fun PagerRemoveAnimationSample() { + + val viewModel = remember { + MyViewModel() + } + + val list = viewModel.list + + Column( + modifier = Modifier + .fillMaxSize() +// .padding(16.dp) + ) { + + val pagerState = rememberPagerState { + list.size + } + + var userGestureEnabled by remember { + mutableStateOf(true) + } + + HorizontalPager( + modifier = Modifier.fillMaxWidth(), + state = pagerState, + pageSpacing = 16.dp, + contentPadding = PaddingValues(start = 16.dp, end = 12.dp), + userScrollEnabled = userGestureEnabled, + key = { + list[it].id + } + ) { page: Int -> + list.getOrNull(page)?.let { item -> + + val animate = item.isAlive.not() + + Column( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .animatePagerItem( + animate = animate, + page = page, + list = list, + pagerState = pagerState, + onStart = { + userGestureEnabled = false + }, + onFinish = { + userGestureEnabled = true + viewModel.removeItem(it) + } + ) + .shadow(2.dp, RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(32.dp) + ) { + Text( + "value: ${item.value}\n" + + "isScrollInProgress: ${pagerState.isScrollInProgress}\n" + + "fraction: ${pagerState.currentPageOffsetFraction}\n" + + "currentPage: ${pagerState.currentPage}\n" + + "settledPage: ${pagerState.settledPage}" + ) + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + if (userGestureEnabled) { + viewModel.updateStatus(page) + } + } + ) { + Text("Remove ${item.value}") + } + } + } + } + } +} + +fun Modifier.animatePagerItem( + animate: Boolean, + page: Int, + list: List, + pagerState: PagerState, + onStart: (page: Int) -> Unit, + onFinish: (page: Int) -> Unit, +) = composed { + + val animatable = remember { + Animatable(1f) + } + + LaunchedEffect(animate) { + if (animate) { + val animationSpec = tween(1000) + + onStart(page) + launch { + try { + animatable.animateTo( + targetValue = 0f, + animationSpec = animationSpec, + ) + onFinish(page) + } catch (e: CancellationException) { + println("CANCELED $page, ${e.message}") + onFinish(page) + } + } + + if (list.size> 1 && page != list.lastIndex) { + launch { + pagerState.animateScrollToPage( + page = page + 1, + animationSpec = animationSpec + ) + } + } else if (page == list.lastIndex) { + pagerState.animateScrollToPage( + page = page - 1, + animationSpec = animationSpec + ) + } + } + } + + Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + + val difference = -constraints.maxWidth * (1 - animatable.value) + + layout(placeable.width, placeable.height) { + placeable.placeRelativeWithLayer(0, 0) { + + transformOrigin = TransformOrigin(0f, .5f) + translationX = if (list.size> 1 && page != list.lastIndex) { + -difference + } else if (list.size> 1 && page == list.lastIndex) { + difference + } else 0f + + translationY = -300f * (1 - animatable.value) + alpha = animatable.value + + // Other animations +// scaleY = (animatable.value).coerceAtLeast(.8f) + cameraDistance = (1 - animatable.value) * 100 +// rotationY = -30f * (1 - animatable.value) +// scaleX = animatable.value + + } + } + } +} \ No newline at end of file diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/LazyRowSnapAndDeleteAnimation.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/LazyRowSnapAndDeleteAnimation.kt new file mode 100644 index 00000000..9f30f1cd --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/LazyRowSnapAndDeleteAnimation.kt @@ -0,0 +1,115 @@ +package com.smarttoolfactory.tutorial1_1basics.chapter9_animation + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.snapping.SnapPosition +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.smarttoolfactory.tutorial1_1basics.chapter3_layout.rememberFlingNestedScrollConnection +import com.smarttoolfactory.tutorial1_1basics.ui.backgroundColor +import com.smarttoolfactory.tutorial1_1basics.ui.components.StyleableTutorialText +import com.smarttoolfactory.tutorial1_1basics.ui.components.TutorialHeader + +@Preview +@Composable +private fun LazyRowSnapAndDeleteAnimation() { + + val viewModel = remember { + MyViewModel() + } + + val lazyListState = rememberLazyListState() + Column( + modifier = Modifier + .fillMaxSize() + .background(backgroundColor) + .padding(vertical = 16.dp) + ) { + + val list = viewModel.list + + TutorialHeader("Animate deletion in LazyRow as Pager") + + StyleableTutorialText( + text = "In this example **LazyRow** is transformed into **HorizontalPager** to " + + "be able to use **Modifier.animateItem** to animate deleting items.", + bullets = false + ) + + LazyRow( + modifier = Modifier.fillMaxSize() + // Slow fling speed to match Pager + .nestedScroll(rememberFlingNestedScrollConnection()), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + // Snap to item as Pager when fling ends + flingBehavior = rememberSnapFlingBehavior( + lazyListState = lazyListState, + snapPosition = SnapPosition.Start + ), + state = lazyListState + ) { + + itemsIndexed( + items = list, + // 🔥🔥Without unique keys animations do not work + key = { _, item -> + item.id + } + ) { page, item -> + Column( + modifier = Modifier + .animateItem( + fadeOutSpec = tween(1000), + placementSpec = tween(1000) + ) + .fillParentMaxWidth() + .height(160.dp) + .shadow(2.dp, RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(32.dp) + ) { + + SideEffect { + println("Recomposing item: $page") + } + Text("Item ${item.value}", fontSize = 26.sp) + + Spacer(Modifier.height(16.dp)) + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + viewModel.removeItem(page) + } + ) { + Text("Remove ${item.value}") + } + } + } + } + } +} \ No newline at end of file diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/MutexAnimatableSample.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/MutexAnimatableSample.kt new file mode 100644 index 00000000..2c5f4839 --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/MutexAnimatableSample.kt @@ -0,0 +1,119 @@ +package com.smarttoolfactory.tutorial1_1basics.chapter9_animation + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.MutatorMutex +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +@Preview +@Composable +fun CoroutinesTest() { + + val mutex = remember { + Mutex() + } + + val mutatorMutex = remember { + MutatorMutex() + } + + + val scope = rememberCoroutineScope() + + val animatable = remember { + Animatable(0f) + } + + Column( + modifier = Modifier.fillMaxSize().padding(24.dp) + ) { + + Text( + "Value: ${animatable.value.toInt()}", fontSize = 26.sp + ) + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + scope.launch { + mutex.withLock { + animatable.snapTo(0f) + try { + animatable.animateTo( + targetValue = 100f, + animationSpec = tween(5000, easing = LinearEasing) + ) + } catch (e: CancellationException) { + println("Exception: ${e.message}") + } + } + } + } + ) { + Text("Start with Mutex") + } + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + scope.launch { + animatable.snapTo(0f) + try { + animatable.animateTo( + targetValue = 100f, + animationSpec = tween(5000, easing = LinearEasing) + ) + } catch (e: CancellationException) { + println("Exception: $e") + } + } + } + ) { + Text("Start") + } + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + + scope.launch { + try { + mutatorMutex.mutate { + animatable.snapTo(0f) + try { + animatable.animateTo( + targetValue = 100f, + animationSpec = tween(5000, easing = LinearEasing) + ) + } catch (e: CancellationException) { + println("Exception: $e") + } + } + }catch (e: Exception){ + println("MutatorMutexException: $e") + } + } + } + ) { + Text("Start with Mutex") + } + + } +} \ No newline at end of file diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/ParticleAnimations.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/ParticleAnimations.kt new file mode 100644 index 00000000..b47b5a46 --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/ParticleAnimations.kt @@ -0,0 +1,1436 @@ +package com.smarttoolfactory.tutorial1_1basics.chapter9_animation + +import android.graphics.Bitmap +import android.util.Log +import android.widget.Button +import android.widget.Toast +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.Slider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.packFloats +import androidx.compose.ui.util.unpackFloat1 +import androidx.compose.ui.util.unpackFloat2 +import com.smarttoolfactory.tutorial1_1basics.R +import com.smarttoolfactory.tutorial1_1basics.chapter3_layout.chat.MessageStatus +import com.smarttoolfactory.tutorial1_1basics.chapter3_layout.chat.SentMessageRowAlt +import com.smarttoolfactory.tutorial1_1basics.chapter6_graphics.degreeToRadian +import com.smarttoolfactory.tutorial1_1basics.chapter6_graphics.randomBoolean +import com.smarttoolfactory.tutorial1_1basics.chapter6_graphics.randomInRange +import com.smarttoolfactory.tutorial1_1basics.chapter6_graphics.scale +import com.smarttoolfactory.tutorial1_1basics.ui.Pink400 +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.cos +import kotlin.math.roundToInt +import kotlin.math.sin + +@Preview +@Composable +fun ShakeTest() { + + + val animatable = remember { + Animatable(0f) + } + + val coroutineScope = rememberCoroutineScope() + + Column( + modifier = Modifier.fillMaxSize().padding(32.dp) + ) { + + Image( + modifier = Modifier + .size(100.dp) + .graphicsLayer { + + val progress = animatable.value + val endValue = .95f + val scale = if (progress < endValue) 1f else scale(endValue, 1f, progress, 1f, 0f) + + translationX = if (progress < endValue) size.width * .05f * progress * randomInRange( + -1f, + 1f + ) else 1f + translationY = if (progress < endValue) size.height * .05f * progress * randomInRange( + -1f, + 1f + ) else 1f + + scaleX = scale + scaleY = scale + alpha = scale + }, + painter = painterResource(R.drawable.avatar_2_raster), + contentDescription = null + ) + + + + Spacer(modifier = Modifier.height(30.dp)) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + coroutineScope.launch { + animatable.snapTo(0f) + animatable.animateTo( + targetValue = 1f, + animationSpec = tween(800) + ) + } + } + ) { + Text("Shake") + } + + + } +} + +fun Modifier.shake() = composed { + val animatable = remember { + Animatable(0f) + } + + + LaunchedEffect(Unit) { + animatable.animateTo( + targetValue = 1f, + animationSpec = tween(1000) + ) + } + Modifier.graphicsLayer { + translationX = size.width * .1f * animatable.value * randomInRange(-1f, 1f) + translationY = size.width * .1f * animatable.value * randomInRange(-1f, 1f) + } + +} + +@Preview +@Composable +fun SingleParticleTrajectorySample() { + + var progress by remember { mutableFloatStateOf(0f) } + + var trajectoryProgressStart by remember { + mutableFloatStateOf(0f) + } + + var trajectoryProgressEnd by remember { + mutableFloatStateOf(1f) + } + + val density = LocalDensity.current + + val sizeDp = with(density) { + 1000f.toDp() + } + val sizePx = with(density) { + sizeDp.toPx() + } + val sizePxHalf = sizePx / 2 + + val particleState = rememberParticleState() + + val particleSize = with(density) { + 5.dp.toPx() + } + + LaunchedEffect(trajectoryProgressStart, trajectoryProgressEnd) { + particleState.particleList.clear() + + val velocity = Velocity( + x = sizePxHalf, + y = -sizePxHalf * 4 + ) + + particleState.addParticle( + Particle( + color = Pink400, + initialCenter = Offset( + x = sizePxHalf, + y = sizePxHalf, + ), + initialSize = Size(particleSize, particleSize), + endSize = Size(sizePx, sizePx), + velocity = velocity, + acceleration = Acceleration(0f, -2 * velocity.y), + trajectoryProgressRange = trajectoryProgressStart..trajectoryProgressEnd + ) + ) + } + + Column( + modifier = Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(vertical = 16.dp, horizontal = 8.dp), + ) { + + Canvas( + modifier = Modifier + .border(width = 1.dp, color = Color(0x26000000)) + .size(sizeDp) + ) { + drawLine( + color = Color.Black, + start = Offset(sizePxHalf, 0f), + end = Offset(sizePxHalf, sizePx), + strokeWidth = 2.dp.toPx() + ) + drawLine( + color = Color.Black, + start = Offset(0f, sizePxHalf), + end = Offset(sizePx, sizePxHalf), + strokeWidth = 2.dp.toPx() + ) + + particleState.particleList.forEach { particle -> + particleState.updateParticle(progress, particle) + drawCircle( + alpha = particle.alpha, + color = particle.color, + radius = 5.dp.toPx(), + center = particle.currentPosition, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + val particle = particleState.particleList.firstOrNull() + + particle?.let { + Text( + text = "Progress: ${(progress * 100).toInt() / 100f}\n" + + "trajectory: ${(particle.trajectoryProgress * 100).toInt() / 100f}\n" + + "currentTime: ${(particle.currentTime * 100f).toInt() / 100f}\n", + fontSize = 18.sp + ) + } + + Text("Progress: ${(progress * 100).roundToInt() / 100f}") + Slider( + modifier = Modifier.fillMaxWidth(), + value = progress, + onValueChange = { + progress = it + } + ) + + Text("trajectoryProgressStart: $trajectoryProgressStart") + Slider( + modifier = Modifier.fillMaxWidth(), + value = trajectoryProgressStart, + onValueChange = { + trajectoryProgressStart = it.coerceAtMost(trajectoryProgressEnd) + }, + valueRange = 0f..1f + ) + + Text("trajectoryProgressEnd: $trajectoryProgressEnd") + Slider( + modifier = Modifier.fillMaxWidth(), + value = trajectoryProgressEnd, + onValueChange = { + trajectoryProgressEnd = it.coerceAtLeast(trajectoryProgressStart) + }, + valueRange = 0f..1f + ) + Spacer(Modifier.height(16.dp)) + } +} + +@Preview +@Composable +fun ParticleAnimationSample() { + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.DarkGray) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 100.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + val density = LocalDensity.current + val widthDp = with(density) { + 500.toDp() + } + + val particleState = rememberParticleState( + particleSize = 1.5.dp, + animationSpec = tween(durationMillis = 1800, easing = FastOutSlowInEasing) + ) + + val particleState2 = rememberParticleState( + particleSize = 8.dp, + strategy = DefaultStrategy(), + animationSpec = tween(durationMillis = 1000) + ) + + val particleState3 = rememberParticleState( + particleSize = 1.5.dp + ) + + val context = LocalContext.current + + var progress by remember { + mutableFloatStateOf(0f) + } + + SentMessageRowAlt( + modifier = Modifier + .clickable { + particleState.startAnimation() + } + .disintegrate( + particleState = particleState, + onStart = { + Toast.makeText(context, "Animation started...", Toast.LENGTH_SHORT).show() + }, + onEnd = { + Toast.makeText(context, "Animation ended...", Toast.LENGTH_SHORT).show() + } + ), + quotedImage = R.drawable.avatar_4_raster, + text = "Some long message", + messageTime = "11.02.2024", + messageStatus = MessageStatus.READ + ) + + Spacer(Modifier.height(16.dp)) + + Image( + painter = painterResource(R.drawable.avatar_5_raster), + modifier = Modifier + .size(80.dp) + .clickable { + particleState2.startAnimation() + } + .disintegrate( + progress = progress, + particleState = particleState2, + onStart = { + Toast.makeText(context, "Animation started...", Toast.LENGTH_SHORT).show() + }, + onEnd = { + Toast.makeText(context, "Animation ended...", Toast.LENGTH_SHORT).show() + } + ), + contentDescription = null + ) + + Spacer(Modifier.height(16.dp)) + + Image( + painter = painterResource(R.drawable.avatar_2_raster), + modifier = Modifier + .border(2.dp, Color.Red) + .size(widthDp) + .clickable { + particleState3.startAnimation() + } + .disintegrate( + progress = progress, + particleState = particleState3, + onStart = { + Toast.makeText(context, "Animation started...", Toast.LENGTH_SHORT).show() + }, + onEnd = { + Toast.makeText(context, "Animation ended...", Toast.LENGTH_SHORT).show() + } + ), + contentDescription = null + ) + + Text( + text = "Progress: ${(progress * 100).roundToInt() / 100f}", + fontSize = 22.sp, + color = Color.White + ) + + Slider( + value = progress, + onValueChange = { + progress = it + } + ) + } +} + +fun Modifier.disintegrate( + progress: Float, + particleState: ParticleState, + onStart: () -> Unit = {}, + onEnd: () -> Unit = {} +) = composed { + + val graphicsLayer = rememberGraphicsLayer() + + val animationStatus = particleState.animationStatus + val density = LocalDensity.current + val particleSizePx = with(density) { particleState.particleSize.roundToPx() } + + LaunchedEffect(animationStatus != AnimationStatus.Idle) { + if (animationStatus != AnimationStatus.Idle) { + + withContext(Dispatchers.Default) { + + val currentBitmap = particleState.bitmap?.asAndroidBitmap() + + val bitmap = + if (currentBitmap == null || currentBitmap.isRecycled) { + graphicsLayer + .toImageBitmap() + .asAndroidBitmap() + .copy(Bitmap.Config.ARGB_8888, false) + .apply { + this.prepareToDraw() + } + + } else particleState.bitmap?.asAndroidBitmap() + + bitmap?.let { + particleState.bitmap = bitmap.asImageBitmap() + particleState.createParticles( + particleList = particleState.particleList, + particleSize = particleSizePx, + bitmap = bitmap + ) + + withContext(Dispatchers.Main) { + particleState.animationStatus = AnimationStatus.Playing + particleState.animate( + onStart = onStart, + onEnd = onEnd + ) + } + } + } + } + } + + Modifier + .drawWithCache { + onDrawWithContent { + if (animationStatus != AnimationStatus.Playing) { + drawContent() + graphicsLayer.record { + this@onDrawWithContent.drawContent() + } + } else { + particleState.updateAndDrawParticles( + drawScope = this, + particleList = particleState.particleList, + bitmap = particleState.bitmap, + progress = progress + ) + } + } + } +} + +fun Modifier.disintegrate( + particleState: ParticleState, + onStart: () -> Unit = {}, + onEnd: () -> Unit = {} +) = composed { + + LaunchedEffect(Unit) { + particleState.forceUpdateProgress = false + } + + Modifier.disintegrate( + progress = particleState.progress, + particleState = particleState, + onStart = onStart, + onEnd = onEnd + ) +} + +@Composable +fun rememberParticleState( + particleSize: Dp = 2.dp, + animationSpec: AnimationSpec = tween( + durationMillis = 750, + easing = LinearEasing + ), + strategy: ParticleStrategy = DisintegrateStrategy(), + particleBoundaries: ParticleBoundaries? = null +): ParticleState { + return remember( +// particleSize, strategy, animationSpec, particleBoundaries + ) { + ParticleState( + particleSize = particleSize, + animationSpec = animationSpec, + strategy = strategy, + particleBoundaries = particleBoundaries + ) + } +} + +@Stable +class ParticleState internal constructor( + val particleSize: Dp, + val animationSpec: AnimationSpec, + val strategy: ParticleStrategy, + val particleBoundaries: ParticleBoundaries?, +) { + val animatable = Animatable(0f) + val particleList = mutableStateListOf() + + var animationStatus by mutableStateOf(AnimationStatus.Idle) + internal set + + val progress: Float + get() = animatable.value + + var bitmap: ImageBitmap? = null + internal set + + internal var forceUpdateProgress: Boolean = true + + fun addParticle(particle: Particle) { + particleList.add(particle) + } + + fun updateAndDrawParticles( + drawScope: DrawScope, + particleList: SnapshotStateList, + bitmap: ImageBitmap?, + progress: Float + ) { + if (animationStatus != AnimationStatus.Idle) { + bitmap?.let { + strategy.updateAndDrawParticles( + drawScope = drawScope, + particleList = particleList, imageBitmap = bitmap, + progress = progress, + particleBoundaries = particleBoundaries + ) + } + } + } + + fun createParticles( + particleList: SnapshotStateList, + particleSize: Int, + bitmap: Bitmap, + ) { + strategy.createParticles( + particleList = particleList, + particleSize = particleSize, + bitmap = bitmap, + particleBoundaries = particleBoundaries + ) + } + + fun updateParticle(progress: Float, particle: Particle) { + strategy.updateParticle( + progress = progress, + particle = particle + ) + } + + fun startAnimation() { + animationStatus = AnimationStatus.Initializing + } + + suspend fun animate( + onStart: () -> Unit, + onEnd: () -> Unit + ) { + try { + onStart() + animatable.snapTo(0f) + if (forceUpdateProgress.not()) { + animatable.animateTo( + targetValue = 1f, + animationSpec = animationSpec + ) + } + } catch (e: CancellationException) { + Log.e("Particle", "${e.message}") + } finally { + if (forceUpdateProgress.not()) { + animationStatus = AnimationStatus.Idle + onEnd() + } + } + } + + fun dispose() { + bitmap?.asAndroidBitmap()?.recycle() + } +} + +open class DisintegrateStrategy : ParticleStrategy { + + override fun createParticles( + particleList: SnapshotStateList, + particleSize: Int, + bitmap: Bitmap, + particleBoundaries: ParticleBoundaries? + ) { + particleList.clear() + + val width = bitmap.width + val height = bitmap.height + + val particleRadius = particleSize / 2 + + // divide image into squares based on particle size + // 110x100x image is divided into 10x10 squares + + for (column in 0 until width step particleSize) { + for (row in 0 until height step particleSize) { + + // Get pixel at center of this pixel rectangle + // If last pixel is out of image get it from end of the width or height + // 🔥x must be < bitmap.width() and y must be < bitmap.height() + + val pixelCenterX = (column + particleRadius).coerceAtMost(width - 1) + val pixelCenterY = (row + particleRadius).coerceAtMost(height - 1) + + val pixel: Int = bitmap.getPixel(pixelCenterX, pixelCenterY) + val color = Color(pixel) + + if (color != Color.Unspecified) { + + // Set center + val initialCenter = + setCenter( + particleSize = particleSize, + pixelCenter = Offset(pixelCenterX.toFloat(), pixelCenterY.toFloat()), + halfWidth = width / 2f, + halfHeight = height / 2f + ) + + // If this particle is at 20% of image width in x plane + // it returns 0.2f + val fractionToImageWidth = (initialCenter.x - particleRadius) / width + val trajectoryProgressRange = setTrajectoryInterval(fractionToImageWidth) + + // Set Velocity + val velocity = setVelocity( + width = width, + height = height, + particleBoundaries = particleBoundaries, + particleSize = particleSize, + fractionToImageWidth = fractionToImageWidth + ) + + // Set acceleration + val acceleration = setAcceleration(particleBoundaries, velocity) + + // Set initial and final sizes + val particleSizePx = particleSize.toFloat() + + val initialSize = setInitialSize(particleBoundaries, particleSizePx) + val endSize = setEndSize(particleBoundaries, particleSizePx) + + // Set alpha + val alphaStart = setInitialAlpha(particleBoundaries) + val alphaEnd = setEndAlpha(particleBoundaries) + + particleList.add( + Particle( + initialCenter = initialCenter, + initialSize = initialSize, + endSize = endSize, + trajectoryProgressRange = trajectoryProgressRange, + color = color, + velocity = velocity, + acceleration = acceleration, + initialAlpha = alphaStart, + endAlpha = alphaEnd + ) + ) + } else { + println("Not adding transparent pixel") + } + } + } + } + + override fun setTrajectoryInterval(fractionToImageWidth: Float): ClosedRange { + // Get trajectory for each 5 percent of the image in x direction + // This creates wave effect where particles at the start animation earlier + val sectionFraction = ParticleCreationFraction / 10f + + // This range is between 0-0.5f to display all of the particles + // until half of the progress is reached + var trajectoryProgressRange: ClosedRange = getTrajectoryRange( + fraction = fractionToImageWidth, + sectionFraction = sectionFraction, + until = ParticleCreationFraction + ) + + // Add randomization for trajectory so particles don't start + // animating in each 5% section vertically + val minOffset = randomInRange(-sectionFraction, sectionFraction) + + val start = (trajectoryProgressRange.start + minOffset) + .coerceAtLeast(0f) + val end = (start + 0.5f).coerceAtMost(1f) + trajectoryProgressRange = start..end + return trajectoryProgressRange + } + + override fun setCenter( + particleSize: Int, + pixelCenter: Offset, + halfWidth: Float, + halfHeight: Float + ): Offset { + return pixelCenter + } + + override fun setInitialSize( + particleBoundaries: ParticleBoundaries?, + particleSize: Float + ): Size { + val initialMinSize = + (particleBoundaries?.startSizeLowerBound?.width) ?: particleSize + val initialMaxSizeSize = + (particleBoundaries?.startSizeUpperBound?.width) ?: particleSize + val initialWidth = randomInRange(initialMinSize, initialMaxSizeSize) + val initialSize = Size(initialWidth, initialWidth) + return initialSize + } + + override fun setEndSize( + particleBoundaries: ParticleBoundaries?, + particleSize: Float + ): Size { + val endMinSize = + (particleBoundaries?.endSizeLowerBound?.width) ?: (particleSize * .4f) + val endMaxSize = + (particleBoundaries?.endSizeUpperBound?.width) ?: (particleSize * .7f) + val endWidth = randomInRange(endMinSize, endMaxSize) + val endSize = Size(endWidth, endWidth) + return endSize + } + + override fun setVelocity( + width: Int, + height: Int, + particleBoundaries: ParticleBoundaries?, + particleSize: Int, + fractionToImageWidth: Float + ): Velocity { + val imageMinDimension = width.coerceAtMost(height) * 1f + val velocityHorizontalMin = + particleBoundaries?.velocityLowerBound?.x ?: -(particleSize * 20f) + val velocityHorizontalMax = + particleBoundaries?.velocityUpperBound?.x ?: (particleSize * 20f) + + val velocityVerticalMin = + particleBoundaries?.velocityLowerBound?.y ?: -(particleSize * 30f) + val velocityVerticalMax = + particleBoundaries?.velocityUpperBound?.y ?: (particleSize * 30f) + + val velocityX = randomInRange( + // Particles close to end should have less randomization compared + // to start of image + (velocityHorizontalMin * (1 - fractionToImageWidth)) + .coerceAtMost(imageMinDimension), + (velocityHorizontalMax) + .coerceAtMost(imageMinDimension) + ) + val velocityY = randomInRange( + (velocityVerticalMin).coerceAtMost(imageMinDimension), + (velocityVerticalMax).coerceAtMost(imageMinDimension) + ) + + val velocity = Velocity(x = velocityX, y = velocityY) + return velocity + } + + override fun setAcceleration( + particleBoundaries: ParticleBoundaries?, + velocity: Velocity + ): Acceleration { + val accelerationHorizontalMin = + particleBoundaries?.accelerationLowerBound?.x ?: 0f + val accelerationHorizontalMax = + particleBoundaries?.accelerationUpperBound?.x ?: 0f + + val accelerationVerticalMin = + particleBoundaries?.accelerationLowerBound?.y ?: (-velocity.y * .1f) + val accelerationVerticalMax = + particleBoundaries?.accelerationUpperBound?.y ?: (-velocity.y * .2f) + + val acceleration = Acceleration( + randomInRange(accelerationHorizontalMin, accelerationHorizontalMax), + randomInRange(accelerationVerticalMin, accelerationVerticalMax) + ) + return acceleration + } + + override fun setInitialAlpha(particleBoundaries: ParticleBoundaries?): Float { + val alphaStartMin = (particleBoundaries?.alphaLowerBound?.start) ?: 1f + val alphaStartMax = (particleBoundaries?.alphaUpperbound?.endInclusive) ?: 1f + val alphaStart = randomInRange(alphaStartMin, alphaStartMax) + return alphaStart + } + + override fun setEndAlpha(particleBoundaries: ParticleBoundaries?): Float { + val alphaEndMin = (particleBoundaries?.alphaLowerBound?.start) ?: 0f + val alphaEndMax = (particleBoundaries?.alphaUpperbound?.endInclusive) ?: 0f + val alphaEnd = randomInRange(alphaEndMin, alphaEndMax) + return alphaEnd + } + + override fun updateAndDrawParticles( + drawScope: DrawScope, + particleList: SnapshotStateList, + imageBitmap: ImageBitmap, + progress: Float, + particleBoundaries: ParticleBoundaries? + ) { + with(drawScope) { + drawWithLayer { + particleList.forEach { particle -> +// if (particle.isActive) { + updateParticle( + progress = progress, + particle = particle + ) + + val color = particle.color + val radius = particle.currentSize.width * .5f + val position = particle.currentPosition + val alpha = particle.alpha + + // Destination + drawCircle( + color = color, + radius = radius, + center = position, + alpha = alpha + ) +// } + } + + if (progress < .65f) { + val coEfficient = if (progress < .2f) { + progress * .8f + } else progress * 1.5f + clipRect( + left = size.width * coEfficient + ) { + drawImage( + image = imageBitmap, + blendMode = BlendMode.DstIn + ) + + drawImage( + image = imageBitmap, + blendMode = BlendMode.SrcOut + ) + } + } + + // For debugging +// drawRect( +// color = Color.Black, +// topLeft = Offset(progress * size.width, 0f), +// size = Size(size.width - progress * size.width, size.height), +// style = Stroke(4.dp.toPx()) +// ) + } + } + } + + override fun updateParticle( + progress: Float, + particle: Particle + ) { + particle.run { + // Trajectory progress translates progress from 0f-1f to + // trajectoryStart-trajectoryEnd + // range. For instance, for trajectory with 0.1-0.6f, trajectoryProgress starts when + // progress is at 0.1f and reaches 1f when progress is at 0.6f. + + // Scale from trajectory to progress, for 0.1-06f trajectory 0.35f(half of range) + // corresponds to 0.5f, to trajectoryProgress + setTrajectoryProgress(progress) + + currentTime = trajectoryProgress + + // Set size + val width = + initialSize.width + (endSize.width - initialSize.width) * currentTime + val height = + initialSize.height + (endSize.height - initialSize.height) * currentTime + currentSize = Size(width, height) + + // Set alpha + // While trajectory progress is less than 40% have full alpha then slowly + // reduce to zero for particles to disappear + alpha = if (trajectoryProgress == 0f) 0f + else if (trajectoryProgress < .4f) 1f + else scale(.4f, 1f, trajectoryProgress, particle.initialAlpha, particle.endAlpha) + + // Set position + val horizontalDisplacement = + velocity.x * currentTime + 0.5f * acceleration.x * currentTime * currentTime + val verticalDisplacement = + velocity.y * currentTime + 0.5f * acceleration.y * currentTime * currentTime + + currentPosition = Offset( + x = initialCenter.x + horizontalDisplacement, + y = initialCenter.y + verticalDisplacement + ) + } + } +} + +data class ParticleBoundaries( + val velocityLowerBound: Velocity? = null, + val velocityUpperBound: Velocity? = null, + val accelerationLowerBound: Acceleration? = null, + val accelerationUpperBound: Acceleration? = null, + val startSizeLowerBound: Size? = null, + val startSizeUpperBound: Size? = null, + val endSizeLowerBound: Size? = null, + val endSizeUpperBound: Size? = null, + val alphaLowerBound: ClosedRange? = null, + val alphaUpperbound: ClosedRange? = null, + val trajectoryProgressRange: ClosedRange? = null, + val anchor: TransformOrigin? = null +) + +open class DefaultStrategy : ParticleStrategy { + + override fun createParticles( + particleList: SnapshotStateList, + particleSize: Int, + bitmap: Bitmap, + particleBoundaries: ParticleBoundaries? + ) { + particleList.clear() + + val width = bitmap.width + val height = bitmap.height + + val particleRadius = particleSize / 2 + + // divide image into squares based on particle size + // 110x100x image is divided into 10x10 squares + + for (column in 0 until width step particleSize) { + for (row in 0 until height step particleSize) { + createParticle( + column, + particleRadius, + width, + row, + height, + bitmap, + particleSize, + particleBoundaries + )?.let { + particleList.add(it) + } + } + } + } + + private fun createParticle( + column: Int, + particleRadius: Int, + width: Int, + row: Int, + height: Int, + bitmap: Bitmap, + particleSize: Int, + particleBoundaries: ParticleBoundaries?, + ): Particle? { + // Get pixel at center of this pixel rectangle + // If last pixel is out of image get it from end of the width or height + // 🔥x must be < bitmap.width() and y must be < bitmap.height() + + val pixelCenterX = (column + particleRadius).coerceAtMost(width - 1) + val pixelCenterY = (row + particleRadius).coerceAtMost(height - 1) + + val pixel: Int = bitmap.getPixel(pixelCenterX, pixelCenterY) + val color = Color(pixel) + + if (color != Color.Unspecified) { + + val halfWidth = width / 2f + val halfHeight = height / 2f + + // Set center + val initialCenter = setCenter( + particleSize, + Offset( + pixelCenterX.toFloat(), + pixelCenterY.toFloat() + ), + halfWidth, + halfHeight + ) + + // Set trajectoryRange + val trajectoryRange = setTrajectoryInterval(0f) + + // Set Velocity + val velocity = setVelocity(width, height, particleBoundaries, particleSize, 0f) + + // Set acceleration + val acceleration = setAcceleration(particleBoundaries, velocity) + + // Set initial and final sizes + val initialSize = setInitialSize(particleBoundaries, particleSize.toFloat()) + + val endSize = setEndSize(particleBoundaries, particleSize.toFloat()) + + // Set alpha + val alphaStart = setInitialAlpha(particleBoundaries) + val alphaEnd = setEndAlpha(particleBoundaries) + + return Particle( + initialCenter = initialCenter, + initialSize = initialSize, + endSize = endSize, + color = color, + velocity = velocity, + acceleration = acceleration, + initialAlpha = alphaStart, + endAlpha = alphaEnd, + trajectoryProgressRange = trajectoryRange + ) + + } else return null + } + + override fun setTrajectoryInterval(fractionToImageWidth: Float): ClosedFloatingPointRange { + val trajectoryStart = randomInRange(0f, 0.03f) + val trajectoryEnd = randomInRange(trajectoryStart, 1f) + val trajectoryRange = trajectoryStart..trajectoryEnd + return trajectoryRange + } + + override fun setCenter( + particleSize: Int, + pixelCenter: Offset, + halfWidth: Float, + halfHeight: Float + ): Offset { + val angle = randomInRange(0f, 360f).degreeToRadian + val radius = randomInRange(0f, 1f * particleSize) + val centerX = halfWidth + radius * cos(angle) + val centerY = halfHeight + radius * sin(angle) + val initialCenter = Offset(centerX, centerY) + return initialCenter + } + + override fun setInitialSize( + particleBoundaries: ParticleBoundaries?, + particleSize: Float + ): Size { + val initialMinSize = + (particleBoundaries?.startSizeLowerBound?.width) ?: particleSize + val initialMaxSizeSize = + (particleBoundaries?.startSizeUpperBound?.width) ?: particleSize + val initialWidth = randomInRange(initialMinSize, initialMaxSizeSize) + val initialSize = Size(initialWidth, initialWidth) + return initialSize + } + + override fun setEndSize( + particleBoundaries: ParticleBoundaries?, + particleSize: Float + ): Size { + val endSizePx = if (randomBoolean(8)) { + randomInRange(particleSize * 1f, particleSize.toFloat() * 2.5f) + } else { + randomInRange(particleSize * .4f, particleSize * 1f) + } + + val endMinSize = + (particleBoundaries?.endSizeLowerBound?.width) ?: (endSizePx) + val endMaxSize = + (particleBoundaries?.endSizeUpperBound?.width) ?: (endSizePx) + + val finalSize = randomInRange(endMinSize, endMaxSize) + + val endSize = Size(finalSize, finalSize) + return endSize + } + + override fun setVelocity( + width: Int, + height: Int, + particleBoundaries: ParticleBoundaries?, + particleSize: Int, + fractionToImageWidth: Float + ): Velocity { + + val halfWidth = width / 2f + val halfHeight = height / 2f + + val velocityHorizontalMin = + particleBoundaries?.velocityLowerBound?.x ?: (-2 * halfWidth) + val velocityHorizontalMax = + particleBoundaries?.velocityUpperBound?.x ?: (2 * halfWidth) + + val velocityVerticalMin = + particleBoundaries?.velocityLowerBound?.y ?: (-1f * halfHeight) + val velocityVerticalMax = + particleBoundaries?.velocityUpperBound?.y ?: (-2f * halfHeight) + + val velocityX = randomInRange(velocityHorizontalMin, velocityHorizontalMax) + val velocityY = randomInRange(velocityVerticalMin, velocityVerticalMax) + val velocity = Velocity(x = velocityX, y = velocityY) + return velocity + } + + override fun setAcceleration( + particleBoundaries: ParticleBoundaries?, + velocity: Velocity + ): Acceleration { + val accelerationHorizontalMin = + particleBoundaries?.accelerationLowerBound?.x ?: 0f + val accelerationHorizontalMax = + particleBoundaries?.accelerationUpperBound?.x ?: 0f + + val accelerationVerticalMin = + particleBoundaries?.accelerationLowerBound?.y ?: (-velocity.y * 2) + val accelerationVerticalMax = + particleBoundaries?.accelerationUpperBound?.y ?: (-velocity.y * 4) + + val acceleration = Acceleration( + randomInRange(accelerationHorizontalMin, accelerationHorizontalMax), + randomInRange(accelerationVerticalMin, accelerationVerticalMax) + ) + return acceleration + } + + override fun setInitialAlpha(particleBoundaries: ParticleBoundaries?): Float { + val alphaStartMin = (particleBoundaries?.alphaLowerBound?.start) ?: 1f + val alphaStartMax = (particleBoundaries?.alphaUpperbound?.endInclusive) ?: 1f + val alphaStart = randomInRange(alphaStartMin, alphaStartMax) + return alphaStart + } + + override fun setEndAlpha(particleBoundaries: ParticleBoundaries?): Float { + val alphaEndMin = (particleBoundaries?.alphaLowerBound?.start) ?: 0f + val alphaEndMax = (particleBoundaries?.alphaUpperbound?.endInclusive) ?: 0f + val alphaEnd = randomInRange(alphaEndMin, alphaEndMax) + return alphaEnd + } + + override fun updateAndDrawParticles( + drawScope: DrawScope, + particleList: SnapshotStateList, + imageBitmap: ImageBitmap, + progress: Float, + particleBoundaries: ParticleBoundaries? + ) { + with(drawScope) { + particleList.forEach { particle -> + updateParticle( + progress = progress, + particle = particle + ) + + val color = particle.color + val radius = particle.currentSize.width * .5f + val position = particle.currentPosition + val alpha = particle.alpha + + // Destination + drawCircle( + color = color, + radius = radius, + center = position, + alpha = alpha + ) + } + + // For debugging +// drawRect( +// color = Color.Black, +// topLeft = Offset(progress * size.width, 0f), +// size = Size(size.width - progress * size.width, size.height), +// style = Stroke(4.dp.toPx()) +// ) + } + } + + override fun updateParticle(progress: Float, particle: Particle) { + particle.run { + // Trajectory progress translates progress from 0f-1f to + // trajectoryStart-trajectoryEnd + // range. For instance, for trajectory with 0.1-0.6f, trajectoryProgress starts when + // progress is at 0.1f and reaches 1f when progress is at 0.6f. + + // Scale from trajectory to progress, for 0.1-06f trajectory 0.35f(half of range) + // corresponds to 0.5f, to trajectoryProgress + setTrajectoryProgress(progress) + + currentTime = scale(0f, 1f, trajectoryProgress, 0f, 1.5f) + + // Set size + val width = + initialSize.width + (endSize.width - initialSize.width) * currentTime + val height = + initialSize.height + (endSize.height - initialSize.height) * currentTime + currentSize = Size(width, height) + + // Set alpha + // While trajectory progress is less than 70% have full alpha then slowly + // reduce to zero for particles to disappear + alpha = if (trajectoryProgress == 0f) 1f + else if (trajectoryProgress < .7f) 1f + else scale(.7f, 1f, trajectoryProgress, particle.initialAlpha, particle.endAlpha) + + // Set position + val horizontalDisplacement = + velocity.x * currentTime + 0.5f * acceleration.x * currentTime * currentTime + val verticalDisplacement = + velocity.y * currentTime + 0.5f * acceleration.y * currentTime * currentTime + + currentPosition = Offset( + x = initialCenter.x + horizontalDisplacement, + y = initialCenter.y + verticalDisplacement + ) + } + } +} + +interface ParticleStrategy { + + fun createParticles( + particleList: SnapshotStateList, + particleSize: Int, + bitmap: Bitmap, + particleBoundaries: ParticleBoundaries? + ) + + fun updateAndDrawParticles( + drawScope: DrawScope, + particleList: SnapshotStateList, + imageBitmap: ImageBitmap, + progress: Float, + particleBoundaries: ParticleBoundaries? + ) + + fun updateParticle( + progress: Float, + particle: Particle + ) + + fun setTrajectoryInterval(fractionToImageWidth: Float): ClosedRange + + fun setCenter( + particleSize: Int, + pixelCenter: Offset, + halfWidth: Float, + halfHeight: Float + ): Offset + + fun setEndSize(particleBoundaries: ParticleBoundaries?, particleSize: Float): Size + + fun setInitialSize(particleBoundaries: ParticleBoundaries?, particleSize: Float): Size + + fun setVelocity( + width: Int, + height: Int, + particleBoundaries: ParticleBoundaries?, + particleSize: Int, + fractionToImageWidth: Float + ): Velocity + + fun setAcceleration( + particleBoundaries: ParticleBoundaries?, + velocity: Velocity + ): Acceleration + + fun setInitialAlpha(particleBoundaries: ParticleBoundaries?): Float + + fun setEndAlpha(particleBoundaries: ParticleBoundaries?): Float +} + +/** + * Calculate range based in [sectionFraction] to create a range for particles to have trajectory. + * This is for animating particles from start to end instead of every particles animating at once. + * + * For [fraction] that might be 0.11, 0.12, 0.15 for [sectionFraction] 0.1 + * range returns 0-0.1 which causes first 10% to start while rest of the particles are stationary. + */ +fun getTrajectoryRange( + fraction: Float, + sectionFraction: Float, + from: Float = 0f, + until: Float = 1f, +): ClosedRange { + + if (sectionFraction == 0f || sectionFraction> 1f) return from..until + val remainder = fraction % sectionFraction + + val min = ((fraction - remainder) * (until - from)).coerceIn(from, until) + val max = (min + sectionFraction).coerceAtMost(until) + + return min..max +} + +private fun Particle.setTrajectoryProgress(progress: Float) { + val trajectoryProgressStart = trajectoryProgressRange.start + val trajectoryProgressEnd = trajectoryProgressRange.endInclusive + + trajectoryProgress = + if (progress < trajectoryProgressStart) { + 0f + } else if (progress> trajectoryProgressEnd) { + 1f + } else { + scale( + a1 = trajectoryProgressStart, + b1 = trajectoryProgressEnd, + x1 = progress, + a2 = 0f, + b2 = 1f + ) + } +} + +data class Particle( + var initialCenter: Offset, + var initialSize: Size, + var endSize: Size, + var trajectoryProgressRange: ClosedRange = 0f..1f, + var initialAlpha: Float = 1f, + val endAlpha: Float = 0f, + var color: Color, + var velocity: Velocity = Velocity(0f, 0f), + var acceleration: Acceleration = Acceleration(0f, 0f) +) { + val isActive: Boolean + get() = alpha> 0f && currentSize != Size.Zero + + var currentPosition: Offset = initialCenter + internal set + var currentSize: Size = initialSize + internal set + var alpha = initialAlpha + internal set + var currentTime: Float = 0f + internal set + var trajectoryProgress: Float = 0f + internal set + + companion object { + val Zero = Particle( + initialCenter = Offset.Zero, + initialSize = Size.Zero, + endSize = Size.Zero, + trajectoryProgressRange = 0f..0f, + initialAlpha = 0f, + endAlpha = 0f, + color = Color.Unspecified, + velocity = Velocity.Zero + ) + } +} + +enum class AnimationStatus { + Idle, Initializing, Playing +} + +private fun DrawScope.drawWithLayer(block: DrawScope.() -> Unit) { + with(drawContext.canvas.nativeCanvas) { + val checkPoint = saveLayer(null, null) + block() + restoreToCount(checkPoint) + } +} + +private const val ParticleCreationFraction = 0.5f + +@Stable +fun Acceleration(x: Float, y: Float) = Acceleration(packFloats(x, y)) + +/** + * A two dimensional velocity in pixels per second. + */ +@Immutable +@JvmInline +value class Acceleration internal constructor(private val packedValue: Long) { + /** + * The horizontal component of the velocity in pixels per second. + */ + @Stable + val x: Float get() = unpackFloat1(packedValue) + + /** + * The vertical component of the velocity in pixels per second. + */ + @Stable + val y: Float get() = unpackFloat2(packedValue) +} diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition1.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition1.kt index 0bbadefa..61aca14e 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition1.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition1.kt @@ -121,7 +121,7 @@ fun ListToDetailsDemo() { Modifier.sharedElement( // 🔥 key should match for // shared element transitions - state = rememberSharedContentState(key = "item-image$item"), + sharedContentState = rememberSharedContentState(key = "item-image$item"), animatedVisibilityScope = this@AnimatedContent, ) } else Modifier diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition3AnimatedVisibility.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition3AnimatedVisibility.kt index 217c2fc5..1d43e069 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition3AnimatedVisibility.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition3AnimatedVisibility.kt @@ -92,7 +92,7 @@ private fun AnimatedVisibilitySharedElementShortenedExample() { SnackContents( snack = snack, modifier = Modifier.sharedElement( - state = rememberSharedContentState(key = snack.name), + sharedContentState = rememberSharedContentState(key = snack.name), animatedVisibilityScope = this@AnimatedVisibility ), onClick = { @@ -114,6 +114,7 @@ private fun AnimatedVisibilitySharedElementShortenedExample() { // [END android_compose_shared_elements_animated_visibility] } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun SharedTransitionScope.SnackEditDetails( snack: Snack?, @@ -156,7 +157,7 @@ fun SharedTransitionScope.SnackEditDetails( SnackContents( snack = targetSnack, modifier = Modifier.sharedElement( - state = rememberSharedContentState(key = targetSnack.name), + sharedContentState = rememberSharedContentState(key = targetSnack.name), animatedVisibilityScope = this@AnimatedContent, ), onClick = { diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition3AnimatedVisibilityBlur.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition3AnimatedVisibilityBlur.kt index 2838afbe..834cb59b 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition3AnimatedVisibilityBlur.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition3AnimatedVisibilityBlur.kt @@ -135,7 +135,7 @@ fun SharedTransitionScope.SnackItem( SnackContents( snack = snack, modifier = Modifier.sharedElement( - state = rememberSharedContentState(key = snack.name), + sharedContentState = rememberSharedContentState(key = snack.name), animatedVisibilityScope = this@AnimatedVisibility, boundsTransform = boundsTransition, ), diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition6RenderOverlay.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition6RenderOverlay.kt index 836f1dec..c518075f 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition6RenderOverlay.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition6RenderOverlay.kt @@ -150,7 +150,7 @@ fun SharedElementRenderInSharedTransitionScopeOverlay() { painter = painterResource(images[item % 3]), modifier = Modifier .sharedElement( - state = rememberSharedContentState(key = "item-image$item"), + sharedContentState = rememberSharedContentState(key = "item-image$item"), animatedVisibilityScope = this@AnimatedContent, ) .fillMaxWidth(), diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition7PlaceHolderSize.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition7PlaceHolderSize.kt index 9a24ecda..596ff4bc 100644 --- a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition7PlaceHolderSize.kt +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition7PlaceHolderSize.kt @@ -1,4 +1,4 @@ -@file:OptIn(ExperimentalSharedTransitionApi::class) +@file:OptIn(ExperimentalSharedTransitionApi::class, ExperimentalSharedTransitionApi::class) package com.smarttoolfactory.tutorial1_1basics.chapter9_animation @@ -177,7 +177,7 @@ fun SharedElement_PlaceholderSize() { painter = painterResource(listSnacks[index].image), modifier = Modifier .sharedElement( - state = rememberSharedContentState(key = "item-image$index"), + sharedContentState = rememberSharedContentState(key = "item-image$index"), animatedVisibilityScope = this@AnimatedContent, // 🔥 Changing placeHolderSize effects how other // items will react during animation diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition9SheetToScreen.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition9SheetToScreen.kt new file mode 100644 index 00000000..c890499d --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition9SheetToScreen.kt @@ -0,0 +1,250 @@ +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class, ExperimentalAnimationSpecApi::class) + +package com.smarttoolfactory.tutorial1_1basics.chapter9_animation + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.ArcMode +import androidx.compose.animation.core.ExperimentalAnimationSpecApi +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.keyframes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.smarttoolfactory.tutorial1_1basics.R +import kotlinx.coroutines.launch + +@Preview +@Composable +private fun SharedElementsample() { + + SharedTransitionLayout(modifier = Modifier.fillMaxSize()) { + val navController = rememberNavController() + NavHost( + navController = navController, + startDestination = "home", + ) { + composable("home") { + BottomSheetImagePicker( + sharedTransitionScope = this@SharedTransitionLayout, + animatedContentScope = this@composable, + onClick = { + navController.navigate("details/$it") + }, + onDismiss = {} + ) + } + + composable( + "details/{item}", + arguments = listOf(navArgument("item") { type = NavType.IntType }) + ) { backStackEntry -> + val item = backStackEntry.arguments?.getInt("item") ?: 0 + Column( + modifier = Modifier.fillMaxSize().background(Color.Black), + verticalArrangement = Arrangement.Center + ) { + Image( + painter = painterResource(item), + modifier = Modifier + .sharedElement( + sharedContentState = rememberSharedContentState(key = item), + animatedVisibilityScope = this@composable, + boundsTransform = gridBoundsTransform + ) + .fillMaxWidth(), + contentScale = ContentScale.Crop, + alignment = Alignment.CenterStart, + contentDescription = null + ) + } + } + } + } +} + +@Composable +private fun BottomSheetImagePicker( + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope, + onDismiss: () -> Unit, + onClick: (Int) -> Unit, +) { + val imageUris = remember { + listOf( + R.drawable.landscape1, + R.drawable.landscape2, + R.drawable.landscape3, + R.drawable.landscape4, + R.drawable.landscape5, + R.drawable.landscape6, + R.drawable.landscape7, + R.drawable.landscape8, + R.drawable.landscape9, + R.drawable.landscape10 + ) + } + + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState( + initialValue = SheetValue.Hidden, + skipHiddenState = false + ) + ) + + BottomSheetScaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState, + sheetPeekHeight = 400.dp, + content = { + + val bottomState = scaffoldState.bottomSheetState + val coroutineScope = rememberCoroutineScope() + + Column( + modifier = Modifier.fillMaxSize().padding(16.dp) + ) { + + Spacer(Modifier.weight(1f)) + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + coroutineScope.launch { + bottomState.partialExpand() + } + } + ) { + Text("Expand") + } + } + if (bottomState.isVisible) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = .3f)) + ) + + } + }, + sheetContent = { + LazyVerticalGrid( + columns = GridCells.Fixed(3), // 3 columns + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp) + ) { + item(span = { GridItemSpan(2) }) { + Box( + modifier = Modifier + .size(200.dp) + .background(Color.Red, RoundedCornerShape(16.dp)) + ) + } + + item { + + Column( + verticalArrangement = Arrangement + .spacedBy(8.dp) + ) { + ImageItem( + sharedTransitionScope, + animatedContentScope, + imageUris[0], + onClick + ) + ImageItem( + sharedTransitionScope, + animatedContentScope, + imageUris[1], + onClick + ) + } + } + + val otherImages = imageUris.drop(2) + items(otherImages) { uri -> + ImageItem( + sharedTransitionScope, + animatedContentScope, + uri, + onClick + ) + } + } + } + ) +} + +@Composable +private fun ImageItem( + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope, + @DrawableRes uri: Int, + onClick: (Int) -> Unit, +) { + with(sharedTransitionScope) { + Image( + modifier = Modifier.sharedElement( + sharedContentState = rememberSharedContentState(key = uri), + animatedVisibilityScope = animatedContentScope, + boundsTransform = gridBoundsTransform + ).clickable { + onClick(uri) + }, + painter = painterResource(uri), + contentScale = ContentScale.Crop, + contentDescription = null + ) + } +} + +val gridBoundsTransform = BoundsTransform { initialBounds, targetBounds -> + keyframes { + durationMillis = 500 + initialBounds at 0 using ArcMode.ArcBelow using FastOutSlowInEasing + targetBounds at 500 + } +} diff --git a/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition9SheetToScreen2.kt b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition9SheetToScreen2.kt new file mode 100644 index 00000000..72bc0ac7 --- /dev/null +++ b/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter9_animation/SharedElementTransition9SheetToScreen2.kt @@ -0,0 +1,338 @@ +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) + +package com.smarttoolfactory.tutorial1_1basics.chapter9_animation + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionOnScreen +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.round +import androidx.compose.ui.unit.toSize +import androidx.compose.ui.window.DialogProperties +import com.smarttoolfactory.tutorial1_1basics.R +import kotlinx.coroutines.android.awaitFrame + +private sealed class AnimationScreen { + data object List : AnimationScreen() + data class Details(val item: Int, val rect: Rect) : AnimationScreen() +} + +@Preview +@Composable +fun SharedElementsample2() { + + BottomSheetImagePicker() +} + +@Composable +fun BottomSheetImagePicker() { + + var state by remember { + mutableStateOf(AnimationScreen.List) + } + + var openBottomSheet by rememberSaveable { mutableStateOf(false) } + val bottomSheetState = rememberModalBottomSheetState() + + // App Content + Column( + modifier = Modifier.fillMaxSize().padding(16.dp) + ) { + + Spacer(Modifier.weight(1f)) + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + openBottomSheet = openBottomSheet.not() + } + ) { + Text("Expand") + } + } + + if (openBottomSheet) { + ModalBottomSheet( + sheetState = bottomSheetState, + modifier = Modifier.fillMaxSize().systemBarsPadding(), + onDismissRequest = { + openBottomSheet = false + } + ) { + BottomSheetPickerContent { uri, rect -> + state = AnimationScreen.Details(uri, rect) + } + } + } + + if (state is AnimationScreen.Details) { + BasicAlertDialog( + modifier = Modifier.fillMaxSize().background(Color.Black), + onDismissRequest = { + state = AnimationScreen.List + }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + + var visible by remember { + mutableStateOf(false) + } + + var expanded by remember { + mutableStateOf(false) + } + + val dispatcher = LocalOnBackPressedDispatcherOwner.current + + BackHandler(visible) { + visible = false + } + + SharedTransitionLayout(modifier = Modifier.fillMaxSize()) { + + LaunchedEffect(Unit) { + awaitFrame() + visible = true + } + + AnimatedContent( + modifier = Modifier.fillMaxSize(), + targetState = visible, + label = "", + ) { visibleState -> + + if (expanded.not()) { + expanded = visibleState && isTransitionActive.not() + } + + val item = (state as? AnimationScreen.Details)?.item ?: R.drawable.landscape2 + val rect = (state as? AnimationScreen.Details)?.rect ?: Rect.Zero + + if (visibleState) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center + ) { + Image( + painter = painterResource(item), + modifier = Modifier + .sharedElement( + sharedContentState = rememberSharedContentState(key = item), + animatedVisibilityScope = this@AnimatedContent, + boundsTransform = gridBoundsTransform + ) + .fillMaxWidth(), + contentScale = ContentScale.Crop, + contentDescription = null + ) + } + } else { + + LaunchedEffect(expanded, isTransitionActive) { + if (expanded && isTransitionActive.not()) { + dispatcher?.onBackPressedDispatcher?.onBackPressed() + } + } + + Box(modifier = Modifier.fillMaxSize()) { + val density = LocalDensity.current + val width: Dp + val height: Dp + + with(density) { + width = rect.width.toDp() + height = rect.height.toDp() + } + Image( + painter = painterResource(item), + modifier = Modifier + .offset { + rect.topLeft.round() + } + .size(width, height) + .sharedElement( + sharedContentState = rememberSharedContentState(key = item), + animatedVisibilityScope = this@AnimatedContent, + boundsTransform = gridBoundsTransform + ), + contentScale = ContentScale.Crop, + contentDescription = null + ) + } + } + } + } + + } + } + +} + +@Composable +private fun BottomSheetPickerContent( + onClick: (Int, Rect) -> Unit, +) { + + val imageUris = remember { + listOf( + R.drawable.landscape1, + R.drawable.landscape2, + R.drawable.landscape3, + R.drawable.landscape4, + R.drawable.landscape5, + R.drawable.landscape6, + R.drawable.landscape7, + R.drawable.landscape8, + R.drawable.landscape9, + R.drawable.landscape10 + ) + } + + val imageMap = remember { + mutableStateMapOf() + } + + val density = LocalDensity.current + val statusBarHeight = WindowInsets.statusBars.getTop(density) + + LazyVerticalGrid( + columns = GridCells.Fixed(3), // 3 columns + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp) + ) { + item(span = { GridItemSpan(2) }) { + Box( + modifier = Modifier + .size(200.dp) + .background(Color.Red, RoundedCornerShape(16.dp)) + ) + } + + item { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + ImageItem( + modifier = Modifier.onGloballyPositioned { + imageMap[imageUris[0]] = Rect( + offset = Offset( + x = it.positionOnScreen().x, + y = it.positionOnScreen().y - statusBarHeight + ), + size = it.size.toSize() + ) + }, + uri = imageUris[0], + onClick = { + onClick(imageUris[0], imageMap[imageUris[0]] ?: Rect.Zero) + } + ) + ImageItem( + modifier = Modifier.onGloballyPositioned { + imageMap[imageUris[1]] = Rect( + offset = Offset( + x = it.positionOnScreen().x, + y = it.positionOnScreen().y - statusBarHeight + ), + size = it.size.toSize() + ) + }, + uri = imageUris[1], + onClick = { + onClick(imageUris[1], imageMap[imageUris[1]] ?: Rect.Zero) + + } + ) + } + } + + val otherImages = imageUris.drop(2) + items(otherImages) { uri -> + ImageItem( + modifier = Modifier.onGloballyPositioned { + imageMap[uri] = Rect( + offset = Offset( + x = it.positionOnScreen().x, + y = it.positionOnScreen().y - statusBarHeight + ), + size = it.size.toSize() + ) + }, + uri = uri, + onClick = { + onClick(uri, imageMap[uri] ?: Rect.Zero) + + } + ) + } + } +} + +@Composable +private fun ImageItem( + modifier: Modifier = Modifier, + @DrawableRes uri: Int, + onClick: (Int) -> Unit, +) { + + Image( + modifier = modifier + .clickable { + onClick(uri) + }, + painter = painterResource(uri), + contentScale = ContentScale.Crop, + contentDescription = null + ) +} diff --git a/Tutorial1-1Basics/src/main/res/values-night/themes.xml b/Tutorial1-1Basics/src/main/res/values-night/themes.xml index 3c661353..2e4107ed 100644 --- a/Tutorial1-1Basics/src/main/res/values-night/themes.xml +++ b/Tutorial1-1Basics/src/main/res/values-night/themes.xml @@ -1,14 +1,8 @@ - + -