Skip to content

Commit fbdf5e9

Browse files
committed
Some improvements for MagicText animations
1 parent 5687cb3 commit fbdf5e9

2 files changed

Lines changed: 120 additions & 83 deletions

File tree

storyboard-text/src/commonMain/kotlin/dev/bnorm/storyboard/text/magic/MagicText.kt

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
package dev.bnorm.storyboard.text.magic
22

33
import androidx.compose.animation.*
4-
import androidx.compose.animation.core.AnimationConstants.DefaultDurationMillis
5-
import androidx.compose.animation.core.Transition
6-
import androidx.compose.animation.core.createChildTransition
7-
import androidx.compose.animation.core.tween
8-
import androidx.compose.animation.core.updateTransition
4+
import androidx.compose.animation.core.*
95
import androidx.compose.foundation.layout.Column
106
import androidx.compose.foundation.layout.Row
117
import androidx.compose.material.Text
@@ -15,39 +11,62 @@ import androidx.compose.runtime.remember
1511
import androidx.compose.ui.Alignment
1612
import androidx.compose.ui.Modifier
1713
import androidx.compose.ui.text.AnnotatedString
14+
import kotlin.jvm.JvmName
15+
16+
const val DefaultMoveDurationMillis = 300
17+
const val DefaultFadeDurationMillis = 300
18+
const val DefaultDelayDurationMillis = 0
1819

1920
@Composable
2021
fun MagicText(
2122
text: AnnotatedString,
2223
modifier: Modifier = Modifier,
23-
// Used to time FadeOut -> Move -> FadeIn animations.
24-
moveDurationMillis: Int = DefaultDurationMillis,
25-
fadeDurationMillis: Int = moveDurationMillis / 2,
24+
// Used to time FadeOut --(Delay)-> Move --(Delay)-> FadeIn animations.
25+
moveDurationMillis: Int = DefaultMoveDurationMillis,
26+
fadeDurationMillis: Int = DefaultFadeDurationMillis,
27+
delayDurationMillis: Int = DefaultDelayDurationMillis,
2628
) {
2729
val words = remember(text) { text.toWords() }
2830
val transition = updateTransition(words)
29-
MagicText(transition, modifier, moveDurationMillis, fadeDurationMillis)
31+
MagicText(transition, modifier, moveDurationMillis, fadeDurationMillis, delayDurationMillis)
32+
}
33+
34+
@Composable
35+
@JvmName("MagicTextAnnotatedString")
36+
fun MagicText(
37+
transition: Transition<AnnotatedString>,
38+
modifier: Modifier = Modifier,
39+
// Used to time FadeOut --(Delay)-> Move --(Delay)-> FadeIn animations.
40+
moveDurationMillis: Int = DefaultMoveDurationMillis,
41+
fadeDurationMillis: Int = DefaultFadeDurationMillis,
42+
delayDurationMillis: Int = DefaultDelayDurationMillis,
43+
) {
44+
val worlds = transition.createChildTransition { remember(it) { it.toWords() } }
45+
MagicText(worlds, modifier, moveDurationMillis, fadeDurationMillis, delayDurationMillis)
3046
}
3147

3248
@Composable
3349
fun MagicText(
3450
tokenizedText: List<AnnotatedString>,
3551
modifier: Modifier = Modifier,
36-
// Used to time FadeOut -> Move -> FadeIn animations.
37-
moveDurationMillis: Int = DefaultDurationMillis,
38-
fadeDurationMillis: Int = moveDurationMillis / 2,
52+
// Used to time FadeOut --(Delay)-> Move --(Delay)-> FadeIn animations.
53+
moveDurationMillis: Int = DefaultMoveDurationMillis,
54+
fadeDurationMillis: Int = DefaultFadeDurationMillis,
55+
delayDurationMillis: Int = DefaultDelayDurationMillis,
3956
) {
4057
val transition = updateTransition(tokenizedText)
41-
MagicText(transition, modifier, moveDurationMillis, fadeDurationMillis)
58+
MagicText(transition, modifier, moveDurationMillis, fadeDurationMillis, delayDurationMillis)
4259
}
4360

4461
@Composable
62+
@JvmName("MagicTextList")
4563
fun MagicText(
4664
transition: Transition<List<AnnotatedString>>,
4765
modifier: Modifier = Modifier,
48-
// Used to time FadeOut -> Move -> FadeIn animations.
49-
moveDurationMillis: Int = DefaultDurationMillis,
50-
fadeDurationMillis: Int = moveDurationMillis / 2,
66+
// Used to time FadeOut --(Delay)-> Move --(Delay)-> FadeIn animations.
67+
moveDurationMillis: Int = DefaultMoveDurationMillis,
68+
fadeDurationMillis: Int = DefaultFadeDurationMillis,
69+
delayDurationMillis: Int = DefaultDelayDurationMillis,
5170
) {
5271
// Keyed on current and target state, so a new transition is created with each new segment.
5372
// This allows re-rendering of the previous text with the new transition keys.
@@ -59,20 +78,20 @@ fun MagicText(
5978
// TODO should we be caching these maps for repeated transitions?
6079
val sharedText = remember {
6180
when (currentState == targetState) {
62-
true -> mapOf(currentState to currentState.map { SharedText(it) })
81+
true -> mapOf(currentState to currentState.map { SharedText(it) }.flatMap { toLines(it) })
6382

6483
false -> {
6584
val (current, target) = findShared(currentState, targetState)
6685
mapOf(
67-
currentState to current, // Re-render the previous text, split up based on diff with the next text.
68-
targetState to target, // Render the next text, split up based on diff with the previous text.
86+
currentState to current.flatMap { toLines(it) }, // Re-render the previous text, split up based on diff with the next text.
87+
targetState to target.flatMap { toLines(it) }, // Render the next text, split up based on diff with the previous text.
6988
)
7089
}
7190
}
7291
}
7392

7493
val child = transition.createChildTransition { text -> sharedText.getValue(text) }
75-
MagicTextInternal(child, modifier, fadeDurationMillis, moveDurationMillis)
94+
MagicTextInternal(child, modifier, fadeDurationMillis, moveDurationMillis, delayDurationMillis)
7695
}
7796
}
7897

@@ -81,9 +100,13 @@ fun MagicText(
81100
private fun MagicTextInternal(
82101
transition: Transition<List<SharedText>>,
83102
modifier: Modifier,
84-
fadeDuration: Int,
85-
moveDuration: Int,
103+
fadeDuration: Int, // Millis
104+
moveDuration: Int, // Millis
105+
delayDuration: Int, // Millis
86106
) {
107+
val moveDelay = delayDuration + fadeDuration
108+
val fadeInDelay = 2 * delayDuration + fadeDuration + moveDuration
109+
87110
SharedTransitionLayout {
88111
transition.AnimatedContent(
89112
modifier = modifier,
@@ -95,19 +118,19 @@ private fun MagicTextInternal(
95118
return when {
96119
// Text is completely different and should fade in and out.
97120
key == null -> Modifier.animateEnterExit(
98-
enter = fadeIn(tween(fadeDuration, delayMillis = moveDuration + fadeDuration)),
99-
exit = fadeOut(tween(fadeDuration)),
121+
enter = fadeIn(tween(fadeDuration, delayMillis = fadeInDelay, easing = EaseInCubic)),
122+
exit = fadeOut(tween(fadeDuration, easing = EaseOutCubic)),
100123
)
101124

102125
// Text content is the same, but the styling may be different,
103126
// so move and cross-fade.
104127
crossFade -> Modifier.sharedBounds(
105128
rememberSharedContentState(key),
106129
animatedVisibilityScope = this@AnimatedContent,
107-
enter = fadeIn(tween(moveDuration, delayMillis = fadeDuration)),
108-
exit = fadeOut(tween(moveDuration, delayMillis = fadeDuration)),
130+
enter = fadeIn(tween(moveDuration, delayMillis = moveDelay, easing = EaseInOut)),
131+
exit = fadeOut(tween(moveDuration, delayMillis = moveDelay, easing = EaseInOut)),
109132
boundsTransform = { _, _ ->
110-
tween(moveDuration, delayMillis = fadeDuration)
133+
tween(moveDuration, delayMillis = moveDelay, easing = EaseInOut)
111134
},
112135
)
113136

@@ -116,14 +139,14 @@ private fun MagicTextInternal(
116139
rememberSharedContentState(key),
117140
animatedVisibilityScope = this@AnimatedContent,
118141
boundsTransform = { _, _ ->
119-
tween(moveDuration, delayMillis = fadeDuration)
142+
tween(moveDuration, delayMillis = moveDelay, easing = EaseInOut)
120143
},
121144
)
122145
}
123146
}
124147

125148
Column(horizontalAlignment = Alignment.Start) {
126-
val iterator = parts.flatMap { toLines(it) }.iterator()
149+
val iterator = parts.iterator()
127150
while (iterator.hasNext()) {
128151
Row {
129152
var itemCount = 0

storyboard-text/src/commonMain/kotlin/dev/bnorm/storyboard/text/magic/SharedText.kt

Lines changed: 68 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ internal fun findShared(
5050

5151
findSharedInternal(beforeNonBlank, afterNonBlank)
5252

53+
// TODO is there a way to merge items to help with large amounts of text that don't change?
5354
return beforeItems.map { it.toSharedItem() } to afterItems.map { it.toSharedItem() }
5455
}
5556

@@ -137,82 +138,95 @@ private fun findSharedInternal(
137138
return count
138139
}
139140

140-
// TODO use priority queue of "key" to "value size" to avoid multiple loops
141-
outer@ while (true) {
142-
// TODO could optimize the management of these maps quite a bit i bet...
143-
var groupedBefore: Map<String, List<MutableSharedText>>
144-
var groupedAfter: Map<String, List<MutableSharedText>>
141+
fun matchUnique() {
142+
val ignoredKeys = mutableSetOf<String>()
145143

146-
unique@ while (true) {
147-
groupedBefore = beforeList.filter { it.key == null }.groupBy { it.value.text }
148-
groupedAfter = afterList.filter { it.key == null }.groupBy { it.value.text }
144+
// TODO use priority queue of "key" to "value size" to avoid multiple loops
145+
outer@ while (true) {
146+
// TODO could optimize the management of these maps quite a bit i bet...
147+
var groupedBefore: Map<String, List<MutableSharedText>>
148+
var groupedAfter: Map<String, List<MutableSharedText>>
149149

150-
val beforeKeys = groupedBefore.filter { it.value.size == 1 }.keys
151-
val afterKeys = groupedAfter.filter { it.value.size == 1 }.keys
150+
unique@ while (true) {
151+
groupedBefore = beforeList.filter { it.key == null }.groupBy { it.value.text }
152+
groupedAfter = afterList.filter { it.key == null }.groupBy { it.value.text }
152153

153-
val sharedKeys = beforeKeys intersect afterKeys
154-
if (sharedKeys.isEmpty()) break@unique
154+
val beforeKeys = groupedBefore.filter { it.value.size == 1 }.keys
155+
val afterKeys = groupedAfter.filter { it.value.size == 1 }.keys
155156

156-
for (key in sharedKeys) {
157-
val before = groupedBefore.getValue(key)[0]
158-
val after = groupedAfter.getValue(key)[0]
159-
associate(before, after)
160-
associateForward(before, after)
161-
associateBackward(before, after)
157+
val sharedKeys = beforeKeys intersect afterKeys
158+
if (sharedKeys.isEmpty()) break@unique
159+
160+
for (key in sharedKeys) {
161+
val before = groupedBefore.getValue(key)[0]
162+
val after = groupedAfter.getValue(key)[0]
163+
associate(before, after)
164+
associateForward(before, after)
165+
associateBackward(before, after)
166+
}
162167
}
163-
}
164168

165-
// TODO improvements:
166-
// - up to 3?
167-
// - sort by count and take first?
168-
// - this is exponential, so it should be limited... maybe?
169-
val beforeKeys = groupedBefore.filter { it.value.size <= 4 }.keys
170-
val afterKeys = groupedAfter.filter { it.value.size <= 4 }.keys
169+
// TODO improvements:
170+
// - up to 3?
171+
// - sort by count and take first?
172+
// - this is exponential, so it should be limited... maybe?
173+
val beforeKeys = groupedBefore.filter { it.value.size <= 4 }.keys
174+
val afterKeys = groupedAfter.filter { it.value.size <= 4 }.keys
175+
176+
val sharedKeys = (beforeKeys intersect afterKeys) - ignoredKeys
177+
if (sharedKeys.isEmpty()) break@outer
171178

172-
val sharedKeys = beforeKeys intersect afterKeys
173-
if (sharedKeys.isEmpty()) break@outer
179+
double@ for (key in sharedKeys) {
180+
val combinations = mutableListOf<Pair<Int, Pair<MutableSharedText, MutableSharedText>>>()
174181

175-
double@ for (key in sharedKeys) {
176-
val combinations = buildList {
177182
val beforeItems = groupedBefore.getValue(key)
178183
val afterItems = groupedAfter.getValue(key)
179184
for (before in beforeItems) {
180185
for (after in afterItems) {
181-
add(before to after)
186+
val strength = countForward(before, after) + countBackward(before, after)
187+
combinations.add(strength to (before to after))
182188
}
183189
}
184-
}
185-
186-
val (before, after) = combinations.maxBy { (before, after) ->
187-
countForward(before, after) + countBackward(before, after)
188-
}
189190

190-
associate(before, after)
191-
associateForward(before, after)
192-
associateBackward(before, after)
191+
combinations.sortBy { -it.first }
193192

194-
continue@outer
193+
if (combinations[0].first == combinations[1].first) {
194+
ignoredKeys.add(key)
195+
} else {
196+
val (before, after) = combinations[0].second
197+
associate(before, after)
198+
associateForward(before, after)
199+
associateBackward(before, after)
200+
continue@outer
201+
}
202+
}
195203
}
196204
}
197205

198-
// Match edges as much as possible as a last option.
199-
if (beforeList.isNotEmpty() && afterList.isNotEmpty()) {
200-
run {
201-
val before = beforeList.first()
202-
val after = afterList.first()
203-
if (before.value.text == after.value.text && before.key == null && after.key == null) {
204-
associate(before, after)
205-
associateForward(before, after)
206+
fun matchEdges() {
207+
// Match edges as much as possible as a last option.
208+
if (beforeList.isNotEmpty() && afterList.isNotEmpty()) {
209+
run {
210+
val before = beforeList.first()
211+
val after = afterList.first()
212+
if (before.value.text == after.value.text && before.key == null && after.key == null) {
213+
associate(before, after)
214+
associateForward(before, after)
215+
}
206216
}
207-
}
208217

209-
run {
210-
val before = beforeList.last()
211-
val after = afterList.last()
212-
if (before.value.text == after.value.text && before.key == null && after.key == null) {
213-
associate(before, after)
214-
associateBackward(before, after)
218+
run {
219+
val before = beforeList.last()
220+
val after = afterList.last()
221+
if (before.value.text == after.value.text && before.key == null && after.key == null) {
222+
associate(before, after)
223+
associateBackward(before, after)
224+
}
215225
}
216226
}
217227
}
228+
229+
matchUnique()
230+
matchEdges()
231+
matchUnique()
218232
}

0 commit comments

Comments
 (0)