Skip to content

Commit 6f5ce41

Browse files
authored
Merge pull request #868 from synonymdev/feat/hero-animation-header
feat: hero swap animation for balance header
2 parents 8c8e25e + 3a377f6 commit 6f5ce41

2 files changed

Lines changed: 177 additions & 26 deletions

File tree

app/src/main/java/to/bitkit/ui/components/BalanceHeaderView.kt

Lines changed: 126 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package to.bitkit.ui.components
22

33
import androidx.compose.animation.AnimatedContent
4+
import androidx.compose.animation.EnterExitState
5+
import androidx.compose.animation.SizeTransform
6+
import androidx.compose.animation.core.animateFloat
47
import androidx.compose.foundation.layout.Arrangement
58
import androidx.compose.foundation.layout.Column
69
import androidx.compose.foundation.layout.Row
@@ -10,9 +13,13 @@ import androidx.compose.foundation.layout.padding
1013
import androidx.compose.foundation.layout.size
1114
import androidx.compose.material3.Icon
1215
import androidx.compose.runtime.Composable
16+
import androidx.compose.runtime.Immutable
1317
import androidx.compose.runtime.getValue
18+
import androidx.compose.runtime.remember
1419
import androidx.compose.ui.Alignment
1520
import androidx.compose.ui.Modifier
21+
import androidx.compose.ui.graphics.TransformOrigin
22+
import androidx.compose.ui.graphics.graphicsLayer
1623
import androidx.compose.ui.platform.LocalInspectionMode
1724
import androidx.compose.ui.platform.testTag
1825
import androidx.compose.ui.res.painterResource
@@ -53,7 +60,7 @@ fun BalanceHeaderView(
5360

5461
if (isPreview) {
5562
BalanceHeader(
56-
modifier = modifier,
63+
isBitcoinPrimary = true,
5764
smallRowSymbol = "$",
5865
smallRowText = "12.34",
5966
largeRowPrefix = prefix,
@@ -66,6 +73,7 @@ fun BalanceHeaderView(
6673
onClick = {},
6774
onToggleHideBalance = {},
6875
testTag = testTag,
76+
modifier = modifier,
6977
)
7078
return
7179
}
@@ -88,7 +96,7 @@ fun BalanceHeaderView(
8896
val isBitcoinPrimary = primaryDisplay == PrimaryDisplay.BITCOIN
8997

9098
BalanceHeader(
91-
modifier = modifier,
99+
isBitcoinPrimary = isBitcoinPrimary,
92100
smallRowSymbol = if (isBitcoinPrimary) fiat.symbol else btc.symbol,
93101
smallRowText = if (isBitcoinPrimary) fiat.formatted else btc.value,
94102
smallRowIsSymbolSuffix = if (isBitcoinPrimary) fiat.isSymbolSuffix else false,
@@ -105,30 +113,64 @@ fun BalanceHeaderView(
105113
onClick = onClick ?: { currency.switchUnit() },
106114
onToggleHideBalance = { settings.setHideBalance(!hideBalance) },
107115
testTag = testTag,
116+
modifier = modifier,
108117
)
109118
}
110119
}
111120

112121
@Composable
113122
fun BalanceHeader(
123+
isBitcoinPrimary: Boolean,
124+
smallRowText: String,
125+
largeRowText: String,
126+
largeRowSymbol: String,
127+
showSymbol: Boolean,
128+
onClick: () -> Unit,
114129
modifier: Modifier = Modifier,
115130
smallRowSymbol: String? = null,
116-
smallRowText: String,
117131
smallRowIsSymbolSuffix: Boolean = false,
118132
smallRowModifier: Modifier = Modifier,
119133
largeRowPrefix: String? = null,
120-
largeRowText: String,
121-
largeRowSymbol: String,
122134
largeRowIsSymbolSuffix: Boolean = false,
123135
largeRowModifier: Modifier = Modifier,
124-
showSymbol: Boolean,
125136
hideBalance: Boolean = false,
126137
isSwipeToHideEnabled: Boolean = false,
127138
showEyeIcon: Boolean = false,
128-
onClick: () -> Unit,
129139
onToggleHideBalance: () -> Unit = {},
130140
testTag: String? = null,
131141
) {
142+
val smallRowState = remember(
143+
isBitcoinPrimary,
144+
smallRowSymbol,
145+
smallRowText,
146+
smallRowIsSymbolSuffix,
147+
) {
148+
SmallRowState(
149+
isBitcoinPrimary,
150+
smallRowSymbol,
151+
smallRowText,
152+
smallRowIsSymbolSuffix,
153+
)
154+
}
155+
156+
val largeRowState = remember(
157+
isBitcoinPrimary,
158+
largeRowPrefix,
159+
largeRowText,
160+
largeRowSymbol,
161+
largeRowIsSymbolSuffix,
162+
showSymbol,
163+
) {
164+
LargeRowState(
165+
isBitcoinPrimary,
166+
largeRowPrefix,
167+
largeRowText,
168+
largeRowSymbol,
169+
largeRowIsSymbolSuffix,
170+
showSymbol,
171+
)
172+
}
173+
132174
Column(
133175
verticalArrangement = Arrangement.Center,
134176
horizontalAlignment = Alignment.Start,
@@ -140,28 +182,62 @@ fun BalanceHeader(
140182
.clickableAlpha { onClick() }
141183
.then(testTag?.let { Modifier.testTag(it) } ?: Modifier)
142184
) {
143-
SmallRow(
144-
symbol = smallRowSymbol,
145-
text = smallRowText,
146-
isSymbolSuffix = smallRowIsSymbolSuffix,
147-
hideBalance = hideBalance,
148-
modifier = smallRowModifier,
149-
)
185+
AnimatedContent(
186+
targetState = smallRowState,
187+
transitionSpec = {
188+
BalanceAnimations.swapSmallRowTransition using
189+
SizeTransform(clip = false)
190+
},
191+
contentKey = { it.isBitcoinPrimary },
192+
label = "smallRowSwapAnimation",
193+
) { state ->
194+
val scale by transition.animateFloat(label = "smallRowScale") {
195+
if (it == EnterExitState.Visible) 1f else SMALL_ROW_SWAP_SCALE
196+
}
197+
SmallRow(
198+
symbol = state.symbol,
199+
text = state.text,
200+
isSymbolSuffix = state.isSymbolSuffix,
201+
hideBalance = hideBalance,
202+
modifier = smallRowModifier.graphicsLayer {
203+
scaleX = scale
204+
scaleY = scale
205+
transformOrigin = TransformOrigin(0f, 0f)
206+
},
207+
)
208+
}
150209

151210
VerticalSpacer(12.dp)
152211

153212
Row(
154213
verticalAlignment = Alignment.CenterVertically,
155214
) {
156-
LargeRow(
157-
prefix = largeRowPrefix,
158-
text = largeRowText,
159-
symbol = largeRowSymbol,
160-
showSymbol = showSymbol,
161-
isSymbolSuffix = largeRowIsSymbolSuffix,
162-
hideBalance = hideBalance,
163-
modifier = largeRowModifier,
164-
)
215+
AnimatedContent(
216+
targetState = largeRowState,
217+
transitionSpec = {
218+
BalanceAnimations.swapLargeRowTransition using
219+
SizeTransform(clip = false)
220+
},
221+
contentKey = { it.isBitcoinPrimary },
222+
label = "largeRowSwapAnimation",
223+
) { state ->
224+
val scale by transition.animateFloat(label = "largeRowScale") {
225+
if (it == EnterExitState.Visible) 1f else LARGE_ROW_SWAP_SCALE
226+
}
227+
LargeRow(
228+
prefix = state.prefix,
229+
text = state.text,
230+
symbol = state.symbol,
231+
showSymbol = state.showSymbol,
232+
isSymbolSuffix = state.isSymbolSuffix,
233+
hideBalance = hideBalance,
234+
modifier = largeRowModifier.graphicsLayer {
235+
scaleX = scale
236+
scaleY = scale
237+
transformOrigin = TransformOrigin(0f, 0f)
238+
},
239+
)
240+
}
165241

166242
if (showEyeIcon) {
167243
Spacer(modifier = Modifier.weight(1f))
@@ -188,6 +264,28 @@ fun BalanceHeader(
188264
}
189265
}
190266

267+
// Matches iOS: .scale(scale: 1.5) for small row, .scale(scale: 0.5) for large row
268+
private const val SMALL_ROW_SWAP_SCALE = 1.5f
269+
private const val LARGE_ROW_SWAP_SCALE = 0.5f
270+
271+
@Immutable
272+
private data class SmallRowState(
273+
val isBitcoinPrimary: Boolean,
274+
val symbol: String?,
275+
val text: String,
276+
val isSymbolSuffix: Boolean,
277+
)
278+
279+
@Immutable
280+
private data class LargeRowState(
281+
val isBitcoinPrimary: Boolean,
282+
val prefix: String?,
283+
val text: String,
284+
val symbol: String,
285+
val isSymbolSuffix: Boolean,
286+
val showSymbol: Boolean,
287+
)
288+
191289
@Composable
192290
fun LargeRow(
193291
prefix: String?,
@@ -295,14 +393,15 @@ private fun SmallRow(
295393
private fun Preview() {
296394
AppThemeSurface {
297395
BalanceHeader(
396+
isBitcoinPrimary = true,
298397
smallRowSymbol = "$",
299398
smallRowText = "27.36",
300399
largeRowPrefix = "+",
301400
largeRowText = "136 825",
302401
largeRowSymbol = "",
303402
showSymbol = true,
403+
onClick = {},
304404
modifier = Modifier.fillMaxWidth(),
305-
onClick = {}
306405
)
307406
}
308407
}
@@ -312,6 +411,7 @@ private fun Preview() {
312411
private fun PreviewHidden() {
313412
AppThemeSurface {
314413
BalanceHeader(
414+
isBitcoinPrimary = true,
315415
smallRowSymbol = "$",
316416
smallRowText = "27.36",
317417
largeRowPrefix = "+",
@@ -320,9 +420,9 @@ private fun PreviewHidden() {
320420
showSymbol = true,
321421
hideBalance = true,
322422
isSwipeToHideEnabled = true,
323-
modifier = Modifier.fillMaxWidth(),
324423
onClick = {},
325-
onToggleHideBalance = {}
424+
onToggleHideBalance = {},
425+
modifier = Modifier.fillMaxWidth(),
326426
)
327427
}
328428
}

app/src/main/java/to/bitkit/ui/shared/animations/BalanceAnimations.kt

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@ package to.bitkit.ui.shared.animations
22

33
import androidx.compose.animation.ContentTransform
44
import androidx.compose.animation.core.EaseInOutCubic
5+
import androidx.compose.animation.core.Spring
6+
import androidx.compose.animation.core.spring
57
import androidx.compose.animation.core.tween
68
import androidx.compose.animation.fadeIn
79
import androidx.compose.animation.fadeOut
810
import androidx.compose.animation.slideInHorizontally
11+
import androidx.compose.animation.slideInVertically
912
import androidx.compose.animation.slideOutHorizontally
13+
import androidx.compose.animation.slideOutVertically
1014
import androidx.compose.animation.togetherWith
15+
import androidx.compose.ui.unit.IntOffset
1116

1217
/**
1318
* Animation specifications for balance hiding/showing transitions.
@@ -83,6 +88,52 @@ object BalanceAnimations {
8388
animationSpec = tween(380)
8489
) + fadeOut(animationSpec = tween(380))
8590

91+
// Matches iOS: Animation.spring(response: 0.3, dampingFraction: 0.8)
92+
private val swapSpring = spring<IntOffset>(
93+
dampingRatio = 0.8f,
94+
stiffness = Spring.StiffnessMediumLow,
95+
)
96+
private val swapFadeSpring = spring<Float>(
97+
dampingRatio = 0.8f,
98+
stiffness = Spring.StiffnessMediumLow,
99+
)
100+
101+
/**
102+
* Swap transition for the small row (top).
103+
* Matches iOS: .move(edge: .bottom) + .opacity + .scale(1.5, anchor: .topLeading)
104+
* Enter from below, exit to below, with spring physics.
105+
*/
106+
val swapSmallRowTransition: ContentTransform =
107+
slideInVertically(
108+
initialOffsetY = { it },
109+
animationSpec = swapSpring,
110+
) + fadeIn(
111+
animationSpec = swapFadeSpring,
112+
) togetherWith slideOutVertically(
113+
targetOffsetY = { it },
114+
animationSpec = swapSpring,
115+
) + fadeOut(
116+
animationSpec = swapFadeSpring,
117+
)
118+
119+
/**
120+
* Swap transition for the large row (bottom).
121+
* Matches iOS: .move(edge: .top) + .opacity + .scale(0.5, anchor: .topLeading)
122+
* Enter from above, exit to above, with spring physics.
123+
*/
124+
val swapLargeRowTransition: ContentTransform =
125+
slideInVertically(
126+
initialOffsetY = { -it },
127+
animationSpec = swapSpring,
128+
) + fadeIn(
129+
animationSpec = swapFadeSpring,
130+
) togetherWith slideOutVertically(
131+
targetOffsetY = { -it },
132+
animationSpec = swapSpring,
133+
) + fadeOut(
134+
animationSpec = swapFadeSpring,
135+
)
136+
86137
/**
87138
* Eye icon transition
88139
* Simple fade for clean appearance/disappearance

0 commit comments

Comments
 (0)