Skip to content

Commit c36bcf0

Browse files
committed
feat: swap animation
1 parent 7c65722 commit c36bcf0

2 files changed

Lines changed: 159 additions & 22 deletions

File tree

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

Lines changed: 106 additions & 22 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,13 +113,14 @@ 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(
114-
modifier: Modifier = Modifier,
123+
isBitcoinPrimary: Boolean,
115124
smallRowSymbol: String? = null,
116125
smallRowText: String,
117126
smallRowIsSymbolSuffix: Boolean = false,
@@ -128,7 +137,24 @@ fun BalanceHeader(
128137
onClick: () -> Unit,
129138
onToggleHideBalance: () -> Unit = {},
130139
testTag: String? = null,
140+
modifier: Modifier = Modifier,
131141
) {
142+
val smallRowState = remember(
143+
isBitcoinPrimary, smallRowSymbol, smallRowText, smallRowIsSymbolSuffix,
144+
) {
145+
SmallRowState(isBitcoinPrimary, smallRowSymbol, smallRowText, smallRowIsSymbolSuffix)
146+
}
147+
148+
val largeRowState = remember(
149+
isBitcoinPrimary, largeRowPrefix, largeRowText, largeRowSymbol,
150+
largeRowIsSymbolSuffix, showSymbol,
151+
) {
152+
LargeRowState(
153+
isBitcoinPrimary, largeRowPrefix, largeRowText, largeRowSymbol,
154+
largeRowIsSymbolSuffix, showSymbol,
155+
)
156+
}
157+
132158
Column(
133159
verticalArrangement = Arrangement.Center,
134160
horizontalAlignment = Alignment.Start,
@@ -140,28 +166,62 @@ fun BalanceHeader(
140166
.clickableAlpha { onClick() }
141167
.then(testTag?.let { Modifier.testTag(it) } ?: Modifier)
142168
) {
143-
SmallRow(
144-
symbol = smallRowSymbol,
145-
text = smallRowText,
146-
isSymbolSuffix = smallRowIsSymbolSuffix,
147-
hideBalance = hideBalance,
148-
modifier = smallRowModifier,
149-
)
169+
AnimatedContent(
170+
targetState = smallRowState,
171+
transitionSpec = {
172+
BalanceAnimations.swapSmallRowTransition using
173+
SizeTransform(clip = false)
174+
},
175+
contentKey = { it.isBitcoinPrimary },
176+
label = "smallRowSwapAnimation",
177+
) { state ->
178+
val scale by transition.animateFloat(label = "smallRowScale") {
179+
if (it == EnterExitState.Visible) 1f else SMALL_ROW_SWAP_SCALE
180+
}
181+
SmallRow(
182+
symbol = state.symbol,
183+
text = state.text,
184+
isSymbolSuffix = state.isSymbolSuffix,
185+
hideBalance = hideBalance,
186+
modifier = smallRowModifier.graphicsLayer {
187+
scaleX = scale
188+
scaleY = scale
189+
transformOrigin = TransformOrigin(0f, 0f)
190+
},
191+
)
192+
}
150193

151194
VerticalSpacer(12.dp)
152195

153196
Row(
154197
verticalAlignment = Alignment.CenterVertically,
155198
) {
156-
LargeRow(
157-
prefix = largeRowPrefix,
158-
text = largeRowText,
159-
symbol = largeRowSymbol,
160-
showSymbol = showSymbol,
161-
isSymbolSuffix = largeRowIsSymbolSuffix,
162-
hideBalance = hideBalance,
163-
modifier = largeRowModifier,
164-
)
199+
AnimatedContent(
200+
targetState = largeRowState,
201+
transitionSpec = {
202+
BalanceAnimations.swapLargeRowTransition using
203+
SizeTransform(clip = false)
204+
},
205+
contentKey = { it.isBitcoinPrimary },
206+
label = "largeRowSwapAnimation",
207+
) { state ->
208+
val scale by transition.animateFloat(label = "largeRowScale") {
209+
if (it == EnterExitState.Visible) 1f else LARGE_ROW_SWAP_SCALE
210+
}
211+
LargeRow(
212+
prefix = state.prefix,
213+
text = state.text,
214+
symbol = state.symbol,
215+
showSymbol = state.showSymbol,
216+
isSymbolSuffix = state.isSymbolSuffix,
217+
hideBalance = hideBalance,
218+
modifier = largeRowModifier.graphicsLayer {
219+
scaleX = scale
220+
scaleY = scale
221+
transformOrigin = TransformOrigin(0f, 0f)
222+
},
223+
)
224+
}
165225

166226
if (showEyeIcon) {
167227
Spacer(modifier = Modifier.weight(1f))
@@ -188,6 +248,28 @@ fun BalanceHeader(
188248
}
189249
}
190250

251+
// Matches iOS: .scale(scale: 1.5) for small row, .scale(scale: 0.5) for large row
252+
private const val SMALL_ROW_SWAP_SCALE = 1.5f
253+
private const val LARGE_ROW_SWAP_SCALE = 0.5f
254+
255+
@Immutable
256+
private data class SmallRowState(
257+
val isBitcoinPrimary: Boolean,
258+
val symbol: String?,
259+
val text: String,
260+
val isSymbolSuffix: Boolean,
261+
)
262+
263+
@Immutable
264+
private data class LargeRowState(
265+
val isBitcoinPrimary: Boolean,
266+
val prefix: String?,
267+
val text: String,
268+
val symbol: String,
269+
val isSymbolSuffix: Boolean,
270+
val showSymbol: Boolean,
271+
)
272+
191273
@Composable
192274
fun LargeRow(
193275
prefix: String?,
@@ -295,14 +377,15 @@ private fun SmallRow(
295377
private fun Preview() {
296378
AppThemeSurface {
297379
BalanceHeader(
380+
isBitcoinPrimary = true,
298381
smallRowSymbol = "$",
299382
smallRowText = "27.36",
300383
largeRowPrefix = "+",
301384
largeRowText = "136 825",
302385
largeRowSymbol = "",
303386
showSymbol = true,
387+
onClick = {},
304388
modifier = Modifier.fillMaxWidth(),
305-
onClick = {}
306389
)
307390
}
308391
}
@@ -312,6 +395,7 @@ private fun Preview() {
312395
private fun PreviewHidden() {
313396
AppThemeSurface {
314397
BalanceHeader(
398+
isBitcoinPrimary = true,
315399
smallRowSymbol = "$",
316400
smallRowText = "27.36",
317401
largeRowPrefix = "+",
@@ -320,9 +404,9 @@ private fun PreviewHidden() {
320404
showSymbol = true,
321405
hideBalance = true,
322406
isSwipeToHideEnabled = true,
323-
modifier = Modifier.fillMaxWidth(),
324407
onClick = {},
325-
onToggleHideBalance = {}
408+
onToggleHideBalance = {},
409+
modifier = Modifier.fillMaxWidth(),
326410
)
327411
}
328412
}

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

Lines changed: 53 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,54 @@ 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+
/**
121+
* Swap transition for the large row (bottom).
122+
* Matches iOS: .move(edge: .top) + .opacity + .scale(0.5, anchor: .topLeading)
123+
* Enter from above, exit to above, with spring physics.
124+
*/
125+
val swapLargeRowTransition: ContentTransform = (
126+
slideInVertically(
127+
initialOffsetY = { -it },
128+
animationSpec = swapSpring,
129+
) + fadeIn(
130+
animationSpec = swapFadeSpring,
131+
) togetherWith slideOutVertically(
132+
targetOffsetY = { -it },
133+
animationSpec = swapSpring,
134+
) + fadeOut(
135+
animationSpec = swapFadeSpring,
136+
)
137+
)
138+
86139
/**
87140
* Eye icon transition
88141
* Simple fade for clean appearance/disappearance

0 commit comments

Comments
 (0)