Skip to content

Commit

Permalink
blog post v1
Browse files Browse the repository at this point in the history
  • Loading branch information
ScottPierce committed Oct 13, 2024
1 parent 31538ec commit b766734
Show file tree
Hide file tree
Showing 35 changed files with 1,047 additions and 0 deletions.
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
Empty file added README.md
Empty file.
1 change: 1 addition & 0 deletions app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
54 changes: 54 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}

android {
namespace = "dev.scottpierce.animation.compose.reveal"
compileSdk = 34

defaultConfig {
applicationId = "dev.scottpierce.animation.compose.reveal"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
}

dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)

debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
28 changes: 28 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Blogcomposerevealanimation"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Blogcomposerevealanimation">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package dev.scottpierce.animation.compose.reveal

import android.util.Log
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
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.UiComposable
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import kotlin.math.max

private const val TAG = "CircularRevealAnimation"

/**
* Animates between the [startContent] and the [endContent] via a CircularReveal that radiates
* outward from the location of the fingers touch location.
*
* @param revealPercentTarget the target percentage of the reveal to animate to.
* @param startContent the starting content of the animation
* @param endContent the ending content of the animation
* @param onPointerEvent
* @param animationSpec
* @param topContentIsTransparent
*/
@Composable
fun CircularRevealAnimation(
revealPercentTarget: Float,
startContent: @Composable @UiComposable () -> Unit,
endContent: @Composable @UiComposable () -> Unit,
modifier: Modifier = Modifier,
onPointerEvent: ((event: PointerEvent, isFingerDown: Boolean) -> Unit)? = null,
animationSpec: AnimationSpec<Float> = spring(),
topContentIsTransparent: Boolean = false,
) {
// Tracks if the finger is up or down in real time
var isFingerDown: Boolean by remember { mutableStateOf(false) }
// Tracks the last position of the finger for the duration of the animation
val fingerOffsetState: MutableState<Offset?> = remember { mutableStateOf(null) }
// The percent of the top layer to clip
val endContentClipPercent by animateFloatAsState(
targetValue = revealPercentTarget,
label = "Circular Reveal Clip Percent",
animationSpec = animationSpec,
finishedListener = {
if (!isFingerDown) {
fingerOffsetState.value = null
}
}
)

Box(
modifier.pointerInput(onPointerEvent) {
awaitPointerEventScope {
while (true) {
val event: PointerEvent = awaitPointerEvent()

when (event.type) {
PointerEventType.Press -> {
isFingerDown = true
val offset = event.changes.last().position
fingerOffsetState.value = offset
}
PointerEventType.Release -> {
if (isFingerDown) {
isFingerDown = false
}
}
PointerEventType.Move -> {
if (isFingerDown) {
val offset = event.changes.last().position
if (
offset.x < 0 ||
offset.y < 0 ||
offset.x > size.width ||
offset.y > size.height
) {
isFingerDown = false
} else {
fingerOffsetState.value = offset
}
}
}
else -> Log.v(TAG, "Unexpected Event type ${event.type}")
}

onPointerEvent?.invoke(event, isFingerDown)
}
}

// Explicitly don't return the awaitPointerEventScope to avoid lint warning
@Suppress("RedundantUnitExpression", "RedundantSuppression")
Unit
},
) {
// Draw the bottom layer if the top layer is transparent, or the top isn't fully animated in.
if (endContentClipPercent < 1f || topContentIsTransparent) {
startContent()
}

val fingerOffset: Offset? = fingerOffsetState.value
// Draw the top layer if it's not being fully clipped by the mask
if (endContentClipPercent > 0f) {
val path: Path = remember { Path() }

val clipModifier: Modifier = if (endContentClipPercent < 1f && fingerOffset != null) {
Modifier.drawWithContent {
path.rewind()

val largestDimension = max(size.width, size.height)

path.addOval(
Rect(
center = fingerOffset,
radius = endContentClipPercent * largestDimension
)
)

clipPath(path) {
this@drawWithContent.drawContent()
}
}
} else {
Modifier
}

Box(
modifier = clipModifier
) {
endContent()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package dev.scottpierce.animation.compose.reveal

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
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.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp

private val BackgroundShape = ShapeDefaults.Medium
private val CheckBoxShape = RoundedCornerShape(4.dp)
private val Height = 52.dp
private val HorizontalPadding = 12.dp

val lightGrey = Color(0x1B000000)

@Composable
fun CircularRevealCheckItem(
text: String,
checked: Boolean,
onCheckedChange: (checked: Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
var isFingerDown: Boolean by remember { mutableStateOf(false) }

CircularRevealAnimation(
revealPercentTarget = if (isFingerDown) {
0.12f
} else {
if (checked) 1f else 0f
},
startContent = {
CheckItemContent(
text = text,
checked = false,
)
},
endContent = {
CheckItemContent(
text = text,
checked = true,
)
},
modifier = modifier,
onPointerEvent = { event, fingerDown ->
when (event.type) {
PointerEventType.Release -> {
if (isFingerDown) {
onCheckedChange(!checked)
}
}
}
isFingerDown = fingerDown
}
)
}

@Composable
private fun CheckItemContent(
text: String,
checked: Boolean,
modifier: Modifier = Modifier,
) {
val typography = MaterialTheme.typography
val backgroundColor: Color = if (checked) Color.Black else Color.White
val textColor: Color = if (checked) Color.White else Color.Black
val fontWeight: FontWeight = if (checked) FontWeight.Bold else FontWeight.Normal

Row(
modifier = modifier
.height(Height)
.fillMaxWidth()
.border(2.dp, lightGrey, BackgroundShape)
.background(backgroundColor, BackgroundShape)
.clip(BackgroundShape),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = text,
modifier = Modifier
.weight(1f)
.padding(start = HorizontalPadding),
style = typography.bodyLarge.copy(
color = textColor,
fontWeight = fontWeight
),
)

Box(
modifier = Modifier
.padding(end = HorizontalPadding)
.size(24.dp)
.let {
if (checked) {
it
} else {
it.border(2.dp, lightGrey, CheckBoxShape)
}
},
contentAlignment = Alignment.Center,
) {
if (checked) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Checkmark",
tint = textColor,
)
}
}
}
}
Loading

0 comments on commit b766734

Please sign in to comment.