Skip to content

Instantly share code, notes, and snippets.

@halilozercan
Last active November 13, 2024 12:04
Show Gist options
  • Save halilozercan/b0aa32d8eecc8871aebc701571fd65f0 to your computer and use it in GitHub Desktop.
Save halilozercan/b0aa32d8eecc8871aebc701571fd65f0 to your computer and use it in GitHub Desktop.
TextField Typing Animation
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