Last active
November 13, 2024 12:04
-
-
Save halilozercan/b0aa32d8eecc8871aebc701571fd65f0 to your computer and use it in GitHub Desktop.
TextField Typing Animation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.example.textfieldtypinganimation | |
import android.os.Bundle | |
import androidx.activity.ComponentActivity | |
import androidx.activity.compose.setContent | |
import androidx.activity.enableEdgeToEdge | |
import androidx.compose.animation.core.Animatable | |
import androidx.compose.animation.core.AnimationVector1D | |
import androidx.compose.animation.core.Spring | |
import androidx.compose.animation.core.spring | |
import androidx.compose.animation.core.tween | |
import androidx.compose.foundation.ExperimentalFoundationApi | |
import androidx.compose.foundation.border | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.foundation.text.BasicTextField | |
import androidx.compose.foundation.text.input.InputTransformation | |
import androidx.compose.foundation.text.input.InputTransformation.Companion.transformInput | |
import androidx.compose.foundation.text.input.TextFieldState | |
import androidx.compose.foundation.text.input.forEachChange | |
import androidx.compose.foundation.text.input.rememberTextFieldState | |
import androidx.compose.material3.LocalTextStyle | |
import androidx.compose.material3.Scaffold | |
import androidx.compose.material3.Text | |
import androidx.compose.material3.TextField | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.mutableStateListOf | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.rememberCoroutineScope | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.draw.drawWithContent | |
import androidx.compose.ui.geometry.Rect | |
import androidx.compose.ui.graphics.ClipOp | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.Path | |
import androidx.compose.ui.graphics.drawscope.clipPath | |
import androidx.compose.ui.graphics.drawscope.clipRect | |
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas | |
import androidx.compose.ui.graphics.drawscope.scale | |
import androidx.compose.ui.graphics.drawscope.translate | |
import androidx.compose.ui.graphics.layer.drawLayer | |
import androidx.compose.ui.graphics.rememberGraphicsLayer | |
import androidx.compose.ui.text.TextLayoutResult | |
import androidx.compose.ui.text.TextRange | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.tooling.preview.Preview | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import androidx.compose.ui.util.fastFilter | |
import androidx.compose.ui.util.fastForEach | |
import androidx.compose.ui.util.fastForEachIndexed | |
import androidx.compose.ui.util.fastForEachReversed | |
import com.example.textfieldtypinganimation.ui.theme.TextFieldTypingAnimationTheme | |
import kotlinx.coroutines.Job | |
import kotlinx.coroutines.launch | |
class MainActivity : ComponentActivity() { | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
enableEdgeToEdge() | |
setContent { | |
TextFieldTypingAnimationTheme { | |
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> | |
App( | |
Modifier | |
.padding(innerPadding) | |
.fillMaxSize() | |
) | |
} | |
} | |
} | |
} | |
} | |
/** | |
* Tracks a range of text as long as it is not partially or completely deleted. | |
*/ | |
@OptIn(ExperimentalFoundationApi::class) | |
class RangeTracker<T : AutoCloseable> { | |
/** | |
* We keep an unordered list of attachment, range pairs. This is a brute-force approach and | |
* can be improved. | |
*/ | |
val attachments = mutableStateListOf<Pair<TextRange, T>>() | |
private fun deletion(range: TextRange) { | |
var i = attachments.size - 1 | |
while (i >= 0) { | |
val (textRange, attachment) = attachments[i] | |
if (textRange.intersects(range)) { | |
attachment.close() | |
attachments.removeAt(i) | |
} else if (textRange.min >= range.max) { | |
attachments[i] = TextRange( | |
textRange.min - range.length, | |
textRange.max - range.length | |
) to attachment | |
} | |
i-- | |
} | |
} | |
private fun insertion(range: TextRange) { | |
var i = attachments.size - 1 | |
while (i >= 0) { | |
val (textRange, attachment) = attachments[i] | |
if (textRange.intersects(range)) { | |
attachment.close() | |
attachments.removeAt(i) | |
} else if (textRange.min >= range.max) { | |
attachments[i] = TextRange( | |
textRange.min + range.length, | |
textRange.max + range.length | |
) to attachment | |
} | |
i-- | |
} | |
} | |
/** | |
* Keeps track of changes. Don't forget to attach this to TextField. | |
*/ | |
val inputTransformation = InputTransformation { | |
changes.forEachChange { range, originalRange -> | |
if (range.collapsed && !originalRange.collapsed) { | |
deletion(originalRange) | |
} else if (!range.collapsed && originalRange.collapsed) { | |
insertion(range) | |
} else { | |
deletion(originalRange) | |
insertion(range) | |
} | |
} | |
} | |
fun track(range: TextRange, attachment: T) { | |
attachments += range to attachment | |
} | |
fun untrack(attachment: T) { | |
attachments.removeIf { it.second == attachment } | |
} | |
} | |
@OptIn(ExperimentalFoundationApi::class) | |
@Composable | |
fun App(modifier: Modifier = Modifier) { | |
val coroutineScope = rememberCoroutineScope() | |
val tracker = remember { RangeTracker<AnimationJob>() } | |
val floatRectArray = remember { FloatArray(1000) { 0f } } | |
Box(modifier) { | |
BasicTextField( | |
rememberTextFieldState(), | |
textStyle = TextStyle(fontSize = 48.sp), | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(8.dp) | |
.border(1.dp, Color.Red, RoundedCornerShape(4.dp)) | |
.padding(8.dp), | |
onTextLayout = { | |
val textLayoutResult = it.invoke() ?: return@BasicTextField | |
// go over waiting animation jobs and fill them if their range is measured | |
tracker | |
.attachments | |
.fastForEach { (range, animationJob) -> | |
if (range.max <= textLayoutResult.layoutInput.text.length) { | |
textLayoutResult.multiParagraph.fillBoundingBoxes( | |
range, | |
floatRectArray, | |
0 | |
) | |
var left = floatRectArray[0] | |
var top = floatRectArray[1] | |
var right = floatRectArray[2] | |
var bottom = floatRectArray[3] | |
var i = 4 | |
while (i < range.length * 4) { | |
left = minOf(left, floatRectArray[i]) | |
top = minOf(top, floatRectArray[i + 1]) | |
right = maxOf(right, floatRectArray[i + 2]) | |
bottom = maxOf(bottom, floatRectArray[i + 3]) | |
i += 4 | |
} | |
animationJob.boundingBox = Rect(left, top, right, bottom) | |
if (animationJob.job == null) { | |
animationJob.job = coroutineScope.launch { | |
animationJob.start() | |
} | |
} | |
} | |
} | |
}, | |
inputTransformation = { | |
with(tracker.inputTransformation) { transformInput() } | |
changes.forEachChange { range, originalRange -> | |
if (!range.collapsed && originalRange.collapsed) { | |
var min = range.min | |
var max = range.max | |
while (min < max && charAt(min).isWhitespace()) { | |
min++ | |
} | |
while (max > min && charAt(max-1).isWhitespace()) { | |
max-- | |
} | |
tracker.track(TextRange(min, max), AnimationJob { | |
tracker.untrack(it) | |
}) | |
} | |
} | |
}, | |
decorator = { | |
// while drawing the innertextfield we now need to create clipped paths where we | |
// draw contents inside the path with a scale animation or anything we like | |
val graphicsLayer = rememberGraphicsLayer() | |
Box(Modifier.drawWithContent { | |
val drawableJobs = | |
tracker.attachments.fastFilter { it.second.boundingBox != null } | |
graphicsLayer.record { | |
[email protected]() | |
} | |
drawIntoCanvas { canvas -> | |
// first we substract all animated areas and then draw the rest | |
canvas.save() | |
drawableJobs | |
.fastForEach { (_, animationJob) -> | |
canvas.clipRect(animationJob.boundingBox!!, ClipOp.Difference) | |
} | |
drawLayer(graphicsLayer) | |
canvas.restore() | |
} | |
// second, we draw all animated areas in their clipped regions | |
drawableJobs.fastForEach { (_, animationJob) -> | |
val boundingBox = animationJob.boundingBox!! | |
scale(animationJob.animatable.value, pivot = boundingBox.center) { | |
clipRect( | |
boundingBox.left, | |
boundingBox.top, | |
boundingBox.right, | |
boundingBox.bottom | |
) { | |
drawLayer(graphicsLayer) | |
} | |
} | |
} | |
}) { | |
it() | |
} | |
} | |
) | |
} | |
} | |
class AnimationJob( | |
val animatable: Animatable<Float, AnimationVector1D> = Animatable(0f), | |
val endListener: (AnimationJob) -> Unit | |
) : AutoCloseable { | |
var boundingBox: Rect? = null | |
var job: Job? = null | |
suspend fun start() { | |
animatable.animateTo( | |
1f, | |
spring(dampingRatio = Spring.DampingRatioHighBouncy, stiffness = Spring.StiffnessLow) | |
) | |
endListener(this) | |
} | |
override fun close() { | |
job?.cancel() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment