Skip to content

Commit fa20021

Browse files
Merge pull request #107 from what3words/task/MT-7339-Recall-button-animation
Mimic recall button animation from main app
2 parents e7a228f + 06c7fb4 commit fa20021

File tree

9 files changed

+149
-35
lines changed

9 files changed

+149
-35
lines changed

lib-compose/src/main/java/com/what3words/components/compose/maps/W3WMapManager.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ class W3WMapManager(
320320
fun setLanguage(language: W3WRFC5646Language) {
321321
this.language = language
322322
}
323-
323+
324324
/**
325325
* Retrieves the current language setting for What3Words addresses.
326326
*
@@ -1613,7 +1613,8 @@ class W3WMapManager(
16131613
private suspend fun handleRecallButton() {
16141614
updateSelectedScreenLocation()
16151615

1616-
val buttonState = buttonState.value
1616+
val buttonState = _buttonState.value
1617+
val cameraState = _mapState.value.cameraState
16171618
val selectedScreenLocation = buttonState.selectedScreenLocation
16181619
val recallButtonViewport = buttonState.recallButtonViewPort
16191620
val mapProjection = buttonState.mapProjection
@@ -1631,6 +1632,7 @@ class W3WMapManager(
16311632
it.copy(
16321633
recallRotationDegree = rotationDegree,
16331634
isRecallButtonVisible = shouldShowRecallButton,
1635+
isCameraMoving = cameraState?.isCameraMoving == true
16341636
)
16351637
}
16361638
}
@@ -1677,7 +1679,8 @@ class W3WMapManager(
16771679

16781680
companion object {
16791681
const val LIST_DEFAULT_ID = "LIST_DEFAULT_ID"
1680-
private val HEADER_MAP_COMPONENT = mapOf("X-W3W-AS-Component" to ("what3words-Map-Android/" + BuildConfig.LIBRARY_VERSION))
1682+
private val HEADER_MAP_COMPONENT =
1683+
mapOf("X-W3W-AS-Component" to ("what3words-Map-Android/" + BuildConfig.LIBRARY_VERSION))
16811684

16821685
/**
16831686
* The default saver implementation for [W3WMapManager].

lib-compose/src/main/java/com/what3words/components/compose/maps/buttons/MapButtons.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,17 @@ internal fun MapButtons(
5757
verticalArrangement = Arrangement.spacedBy(20.dp),
5858
horizontalAlignment = Alignment.End
5959
) {
60-
if (buttonConfig.isRecallButtonAvailable && buttonState.isRecallButtonVisible) {
60+
if (buttonConfig.isRecallButtonAvailable) {
6161
RecallButton(
6262
layoutConfig = layoutConfig.recallButtonLayoutConfig,
6363
onRecallClicked = onRecallClicked,
6464
onRecallButtonPositionProvided = onRecallButtonPositionProvided,
6565
rotation = ceil(buttonState.recallRotationDegree),
6666
recallButtonColor = recallButtonColor,
6767
contentDescription = contentDescription,
68+
isVisible = buttonState.isRecallButtonVisible,
69+
isCameraMoving = buttonState.isCameraMoving,
70+
selectedPosition = buttonState.selectedScreenLocation ?: PointF(),
6871
)
6972
}
7073
if (buttonConfig.isMyLocationButtonAvailable) {

lib-compose/src/main/java/com/what3words/components/compose/maps/buttons/RecallButton.kt

Lines changed: 96 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
package com.what3words.components.compose.maps.buttons
22

33
import android.graphics.PointF
4+
import androidx.compose.animation.AnimatedVisibility
5+
import androidx.compose.animation.core.tween
6+
import androidx.compose.animation.fadeIn
7+
import androidx.compose.animation.fadeOut
8+
import androidx.compose.animation.slideIn
9+
import androidx.compose.animation.slideOut
410
import androidx.compose.foundation.background
511
import androidx.compose.foundation.layout.padding
612
import androidx.compose.foundation.layout.size
713
import androidx.compose.foundation.shape.CircleShape
814
import androidx.compose.material3.Icon
915
import androidx.compose.material3.IconButton
1016
import androidx.compose.runtime.Composable
17+
import androidx.compose.runtime.LaunchedEffect
1118
import androidx.compose.runtime.getValue
1219
import androidx.compose.runtime.mutableStateOf
1320
import androidx.compose.runtime.remember
@@ -18,9 +25,10 @@ import androidx.compose.ui.draw.shadow
1825
import androidx.compose.ui.geometry.Offset
1926
import androidx.compose.ui.graphics.graphicsLayer
2027
import androidx.compose.ui.layout.onGloballyPositioned
21-
import androidx.compose.ui.layout.positionInWindow
28+
import androidx.compose.ui.layout.positionInRoot
2229
import androidx.compose.ui.res.painterResource
2330
import androidx.compose.ui.tooling.preview.Preview
31+
import androidx.compose.ui.unit.IntOffset
2432
import androidx.compose.ui.unit.dp
2533
import com.what3words.components.compose.maps.buttons.W3WMapButtonsDefault.defaultRecallButtonColor
2634
import com.what3words.components.compose.maps.buttons.W3WMapButtonsDefault.defaultRecallButtonLayoutConfig
@@ -36,6 +44,9 @@ import com.what3words.map.components.compose.R
3644
* @param contentDescription The content description for the button.
3745
* @param onRecallClicked The callback when the button is clicked.
3846
* @param onRecallButtonPositionProvided The callback providing the button's position as a PointF.
47+
* @param selectedPosition The position used for calculating the offset for animation, defaults to PointF().
48+
* @param isVisible Determines whether the button should be visible.
49+
* @param isCameraMoving Indicates if the camera is currently moving, affecting button visibility.
3950
*/
4051
@Composable
4152
internal fun RecallButton(
@@ -46,39 +57,87 @@ internal fun RecallButton(
4657
contentDescription: W3WMapButtonsDefault.ContentDescription = W3WMapButtonsDefault.defaultContentDescription(),
4758
onRecallClicked: () -> Unit,
4859
onRecallButtonPositionProvided: (PointF) -> Unit,
60+
selectedPosition: PointF = PointF(),
61+
isVisible: Boolean = false,
62+
isCameraMoving: Boolean = false
4963
) {
5064

51-
var position: Offset by remember { mutableStateOf(Offset.Zero) }
65+
// Track previous isVisible state to detect changes
66+
var prevIsVisible by remember { mutableStateOf(isVisible) }
67+
var shouldBeVisible by remember { mutableStateOf(isVisible) }
68+
5269
val positionCallback = rememberUpdatedState(onRecallButtonPositionProvided)
70+
var buttonPosition: Offset by remember { mutableStateOf(Offset.Unspecified) }
5371

54-
IconButton(
55-
onClick = { onRecallClicked() },
56-
modifier = modifier
57-
.onGloballyPositioned { coordinate ->
58-
// Only trigger one time when the button is initialized
59-
// The coordinate is affected by the rotation
60-
if (position == Offset.Zero) {
61-
position = coordinate.positionInWindow()
62-
val point = PointF(position.x, position.y)
63-
positionCallback.value(point)
64-
}
72+
LaunchedEffect(isVisible, isCameraMoving) {
73+
// Show the button when isVisible is true,
74+
// OR prevIsVisible was true AND we're currently moving the camera (isCameraMoving is true)
75+
shouldBeVisible = isVisible || (prevIsVisible && isCameraMoving)
76+
prevIsVisible = isVisible
77+
}
78+
79+
AnimatedVisibility(
80+
visible = shouldBeVisible,
81+
enter = fadeIn(
82+
initialAlpha = 0f,
83+
animationSpec = tween(400)
84+
) + slideIn(
85+
animationSpec = tween(400),
86+
initialOffset = { size ->
87+
IntOffset(
88+
x = (selectedPosition.x - buttonPosition.x).toInt(),
89+
y = (selectedPosition.y - buttonPosition.y).toInt()
90+
)
6591
}
66-
.padding(layoutConfig.buttonPadding)
67-
.graphicsLayer {
68-
rotationZ = rotation
92+
),
93+
exit = fadeOut(
94+
targetAlpha = 0f,
95+
animationSpec = tween(durationMillis = 400, delayMillis = 200)
96+
) + slideOut(
97+
animationSpec = tween(durationMillis = 400, delayMillis = 200),
98+
targetOffset = { size ->
99+
IntOffset(
100+
x = (selectedPosition.x - buttonPosition.x).toInt(),
101+
y = (selectedPosition.y - buttonPosition.y).toInt()
102+
)
69103
}
70-
.shadow(elevation = 3.dp, shape = CircleShape)
71-
.size(layoutConfig.buttonSize)
72-
.background(recallButtonColor.recallBackgroundColor)
73-
) {
74-
Icon(
75-
modifier = Modifier
76-
.size(layoutConfig.imageSize)
77-
.padding(layoutConfig.imagePadding),
78-
painter = painterResource(R.drawable.ic_arrow_back),
79-
contentDescription = contentDescription.recallButtonDescription,
80-
tint = recallButtonColor.recallArrowColor
81104
)
105+
) {
106+
IconButton(
107+
onClick = { onRecallClicked() },
108+
modifier = modifier
109+
.onGloballyPositioned { coordinate ->
110+
// Get the center position of the recall button
111+
// Only trigger one time when the button is initialized
112+
// NOTE: onGloballyPositioned is called AFTER a composition,
113+
// so the first appear time the button can't perform the animation fully because lack of position info.
114+
if (!buttonPosition.isValid()) {
115+
val size = coordinate.size
116+
val position = coordinate.positionInRoot()
117+
val centerX = position.x + size.width / 2
118+
val centerY = position.y + size.height / 2
119+
val centerPoint = PointF(centerX, centerY)
120+
buttonPosition = Offset(centerX, centerY)
121+
positionCallback.value(centerPoint)
122+
}
123+
}
124+
.padding(layoutConfig.buttonPadding)
125+
.shadow(elevation = 3.dp, shape = CircleShape)
126+
.size(layoutConfig.buttonSize)
127+
.background(recallButtonColor.recallBackgroundColor)
128+
) {
129+
Icon(
130+
modifier = Modifier
131+
.size(layoutConfig.imageSize)
132+
.padding(layoutConfig.imagePadding)
133+
.graphicsLayer {
134+
rotationZ = rotation
135+
},
136+
painter = painterResource(R.drawable.ic_arrow_back),
137+
contentDescription = contentDescription.recallButtonDescription,
138+
tint = recallButtonColor.recallArrowColor
139+
)
140+
}
82141
}
83142
}
84143

@@ -88,6 +147,8 @@ private fun A1() {
88147
RecallButton(
89148
modifier = Modifier,
90149
rotation = 0f,
150+
isVisible = true,
151+
isCameraMoving = false,
91152
onRecallClicked = {},
92153
onRecallButtonPositionProvided = {}
93154
)
@@ -99,6 +160,8 @@ private fun A2() {
99160
RecallButton(
100161
modifier = Modifier,
101162
rotation = 45f,
163+
isVisible = true,
164+
isCameraMoving = false,
102165
onRecallClicked = {},
103166
onRecallButtonPositionProvided = {}
104167
)
@@ -110,6 +173,8 @@ private fun A3() {
110173
RecallButton(
111174
modifier = Modifier,
112175
rotation = 90f,
176+
isVisible = true,
177+
isCameraMoving = false,
113178
onRecallClicked = {},
114179
onRecallButtonPositionProvided = {}
115180
)
@@ -121,6 +186,8 @@ private fun A4() {
121186
RecallButton(
122187
modifier = Modifier,
123188
rotation = 135f,
189+
isVisible = true,
190+
isCameraMoving = false,
124191
onRecallClicked = {},
125192
onRecallButtonPositionProvided = {}
126193
)
@@ -132,6 +199,8 @@ private fun A5() {
132199
RecallButton(
133200
modifier = Modifier,
134201
rotation = 180f,
202+
isVisible = true,
203+
isCameraMoving = false,
135204
onRecallClicked = {},
136205
onRecallButtonPositionProvided = {}
137206
)

lib-compose/src/main/java/com/what3words/components/compose/maps/providers/googlemap/W3WGoogleMap.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,16 @@ fun W3WGoogleMap(
101101
state.cameraState.gridBound = gridBound
102102
state.cameraState.visibleBound = visibleBound
103103
onCameraUpdated(state.cameraState)
104-
105104
}
106105
}
107106
}.launchIn(this)
108107
}
109108

109+
LaunchedEffect(cameraPositionState.isMoving) {
110+
state.cameraState.isCameraMoving = cameraPositionState.isMoving
111+
onCameraUpdated(state.cameraState)
112+
}
113+
110114
GoogleMap(
111115
modifier = modifier,
112116
cameraPositionState = cameraPositionState,

lib-compose/src/main/java/com/what3words/components/compose/maps/providers/mapbox/W3WMapBox.kt

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@ import androidx.compose.ui.Modifier
1212
import androidx.compose.ui.platform.LocalDensity
1313
import com.mapbox.common.toValue
1414
import com.mapbox.maps.CameraBoundsOptions
15+
import com.mapbox.maps.CameraChanged
16+
import com.mapbox.maps.CameraChangedCallback
1517
import com.mapbox.maps.CameraOptions
1618
import com.mapbox.maps.EdgeInsets
19+
import com.mapbox.maps.MapIdle
20+
import com.mapbox.maps.MapIdleCallback
1721
import com.mapbox.maps.MapView
1822
import com.mapbox.maps.MapboxMap
1923
import com.mapbox.maps.extension.compose.MapEffect
@@ -43,6 +47,7 @@ import kotlinx.coroutines.flow.onEach
4347

4448
private const val MAPBOX_MIN_ZOOM_LEVEL = 3.0
4549
private const val MAPBOX_MAX_ZOOM_PITCH = 60.0
50+
private const val MAPBOX_DEFAULT_CAMERA_PADDING = 10.0
4651

4752
/**
4853
* A composable function that displays a What3Words (W3W) map using the Mapbox Maps SDK for Android.
@@ -76,9 +81,15 @@ fun W3WMapBox(
7681
val mapViewportState = (state.cameraState as W3WMapboxCameraState).cameraState
7782

7883
var lastProcessedCameraState by remember { mutableStateOf(mapViewportState.cameraState) }
84+
7985
val density = LocalDensity.current.density
8086
val cameraForCoordinatesPadding = remember(density) {
81-
EdgeInsets(0.0,10.0 * density,0.0,10.0 * density)
87+
EdgeInsets(
88+
0.0,
89+
MAPBOX_DEFAULT_CAMERA_PADDING * density,
90+
0.0,
91+
MAPBOX_DEFAULT_CAMERA_PADDING * density
92+
)
8293
}
8394

8495
LaunchedEffect(mapViewportState.cameraState) {
@@ -152,8 +163,7 @@ fun W3WMapBox(
152163
modifier = modifier,
153164
mapState = mapState,
154165
mapViewportState = mapViewportState,
155-
logo =
156-
{
166+
logo = {
157167
Logo(
158168
modifier = Modifier.padding(layoutConfig.contentPadding)
159169
)
@@ -211,6 +221,24 @@ fun W3WMapBox(
211221
it.mapboxMap.setBounds(cameraBounds)
212222
}
213223

224+
it.mapboxMap.subscribeMapIdle(object : MapIdleCallback {
225+
override fun run(mapIdle: MapIdle) {
226+
if (state.cameraState.isCameraMoving == true) {
227+
state.cameraState.isCameraMoving = false
228+
onCameraUpdated(state.cameraState)
229+
}
230+
}
231+
})
232+
233+
it.mapboxMap.subscribeCameraChanged(object : CameraChangedCallback {
234+
override fun run(cameraChanged: CameraChanged) {
235+
if (state.cameraState.isCameraMoving == false) {
236+
state.cameraState.isCameraMoving = true
237+
onCameraUpdated(state.cameraState)
238+
}
239+
}
240+
})
241+
214242
if (mapConfig.buttonConfig.isRecallButtonAvailable) {
215243
mapView?.mapboxMap?.let { map ->
216244
onMapProjectionUpdated(W3WMapBoxMapProjection(map))

lib-compose/src/main/java/com/what3words/components/compose/maps/state/W3WButtonsState.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ data class W3WButtonsState(
1818
val recallRotationDegree: Float = 0F,
1919
val recallButtonPosition: PointF = PointF(0F, 0F),
2020

21+
val isCameraMoving: Boolean = true,
2122
val mapProjection: W3WMapProjection? = null,
2223
val mapViewPort: W3WGridScreenCell? = null,
2324
val recallButtonViewPort: W3WGridScreenCell? = null,

lib-compose/src/main/java/com/what3words/components/compose/maps/state/camera/W3WCameraState.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ interface W3WCameraState<T> {
2222

2323
var visibleBound: W3WRectangle?
2424

25+
var isCameraMoving: Boolean
26+
2527
/**
2628
* Adjust camera bearing to 0
2729
*/

lib-compose/src/main/java/com/what3words/components/compose/maps/state/camera/W3WGoogleCameraState.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ class W3WGoogleCameraState(initialCameraState: CameraPositionState) :
2828

2929
override var visibleBound: W3WRectangle? by mutableStateOf(null)
3030

31+
override var isCameraMoving: Boolean by mutableStateOf(true)
32+
3133
override suspend fun orientCamera() {
3234
updateCameraPosition(
3335
CameraPosition(

lib-compose/src/main/java/com/what3words/components/compose/maps/state/camera/W3WMapboxCameraState.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ class W3WMapboxCameraState(initialCameraState: MapViewportState) :
2424

2525
override var visibleBound: W3WRectangle? by mutableStateOf(null)
2626

27+
override var isCameraMoving: Boolean by mutableStateOf(true)
28+
2729
//TODO: This is work around for the function cameraForCoordinates not support in compose
2830
var cameraForCoordinates: MutableList<Point>? = mutableListOf()
2931

0 commit comments

Comments
 (0)