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 @@
-
+
-