Skip to content

Commit 6686c48

Browse files
authored
Merge pull request #87 from TimPushkin/pin-pointer
Pin pointer
2 parents df41b45 + c761b2e commit 6686c48

File tree

9 files changed

+416
-8
lines changed

9 files changed

+416
-8
lines changed

app/src/main/java/ru/spbu/depnav/ui/component/FloorSwitch.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.rounded.KeyboardArrowDown
3232
import androidx.compose.material.icons.rounded.KeyboardArrowUp
3333
import androidx.compose.material3.Icon
3434
import androidx.compose.material3.IconButton
35+
import androidx.compose.material3.MaterialTheme
3536
import androidx.compose.material3.Surface
3637
import androidx.compose.material3.Text
3738
import androidx.compose.runtime.Composable
@@ -41,6 +42,7 @@ import androidx.compose.ui.res.stringResource
4142
import androidx.compose.ui.tooling.preview.Preview
4243
import ru.spbu.depnav.R
4344
import ru.spbu.depnav.ui.theme.DepNavTheme
45+
import ru.spbu.depnav.ui.theme.ON_MAP_SURFACE_ALPHA
4446

4547
private const val MIN_FLOOR = 1
4648

@@ -54,7 +56,8 @@ fun FloorSwitch(
5456
) {
5557
Surface(
5658
modifier = modifier,
57-
shape = CircleShape
59+
shape = CircleShape,
60+
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = ON_MAP_SURFACE_ALPHA)
5861
) {
5962
Column(horizontalAlignment = Alignment.CenterHorizontally) {
6063
IconButton(

app/src/main/java/ru/spbu/depnav/ui/component/MapSearchBar.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ import androidx.compose.material.icons.rounded.Menu
4242
import androidx.compose.material3.ExperimentalMaterial3Api
4343
import androidx.compose.material3.Icon
4444
import androidx.compose.material3.IconButton
45+
import androidx.compose.material3.MaterialTheme
4546
import androidx.compose.material3.SearchBar
47+
import androidx.compose.material3.SearchBarDefaults
4648
import androidx.compose.material3.Text
4749
import androidx.compose.material3.minimumInteractiveComponentSize
4850
import androidx.compose.runtime.Composable
@@ -55,6 +57,7 @@ import androidx.compose.ui.res.stringResource
5557
import androidx.compose.ui.text.style.TextOverflow
5658
import ru.spbu.depnav.R
5759
import ru.spbu.depnav.ui.theme.DEFAULT_PADDING
60+
import ru.spbu.depnav.ui.theme.ON_MAP_SURFACE_ALPHA
5861
import ru.spbu.depnav.ui.viewmodel.SearchResults
5962

6063
// These are basically copied from SearchBar implementation
@@ -107,6 +110,9 @@ fun MapSearchBar(
107110

108111
val focusManager = LocalFocusManager.current
109112

113+
val containerColorAlpha =
114+
ON_MAP_SURFACE_ALPHA + (1 - ON_MAP_SURFACE_ALPHA) * activationAnimationProgress
115+
110116
SearchBar(
111117
query = query,
112118
onQueryChange = onQueryChange,
@@ -141,7 +147,12 @@ fun MapSearchBar(
141147
onClearClick = { onQueryChange("") },
142148
modifier = Modifier.padding(end = innerEndPadding)
143149
)
144-
}
150+
},
151+
colors = SearchBarDefaults.colors(
152+
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(
153+
alpha = containerColorAlpha
154+
)
155+
)
145156
) {
146157
val keyboard = LocalSoftwareKeyboardController.current
147158

app/src/main/java/ru/spbu/depnav/ui/component/Pin.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818

1919
package ru.spbu.depnav.ui.component
2020

21-
import androidx.compose.foundation.layout.offset
2221
import androidx.compose.foundation.layout.size
2322
import androidx.compose.material3.Icon
2423
import androidx.compose.material3.MaterialTheme
@@ -29,7 +28,7 @@ import androidx.compose.ui.res.stringResource
2928
import androidx.compose.ui.unit.dp
3029
import ru.spbu.depnav.R
3130

32-
private val SIZE = 30.dp
31+
val PIN_SIZE = 30.dp
3332

3433
/** Pin for highlighting map markers. */
3534
@Composable
@@ -38,8 +37,7 @@ fun Pin(modifier: Modifier = Modifier) {
3837
painter = painterResource(R.drawable.pin),
3938
contentDescription = stringResource(R.string.label_selected_place),
4039
modifier = Modifier
41-
.size(SIZE)
42-
.offset(y = -SIZE / 2)
40+
.size(PIN_SIZE)
4341
.then(modifier),
4442
tint = MaterialTheme.colorScheme.primary
4543
)
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* DepNav -- department navigator.
3+
* Copyright (C) 2024 Timofei Pushkin
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
19+
package ru.spbu.depnav.ui.component
20+
21+
import androidx.compose.animation.AnimatedVisibility
22+
import androidx.compose.animation.fadeIn
23+
import androidx.compose.animation.fadeOut
24+
import androidx.compose.animation.scaleIn
25+
import androidx.compose.animation.scaleOut
26+
import androidx.compose.animation.slideIn
27+
import androidx.compose.animation.slideOut
28+
import androidx.compose.foundation.layout.absoluteOffset
29+
import androidx.compose.runtime.Composable
30+
import androidx.compose.runtime.LaunchedEffect
31+
import androidx.compose.runtime.getValue
32+
import androidx.compose.runtime.mutableStateOf
33+
import androidx.compose.runtime.remember
34+
import androidx.compose.runtime.setValue
35+
import androidx.compose.ui.Modifier
36+
import androidx.compose.ui.draw.rotate
37+
import androidx.compose.ui.platform.LocalDensity
38+
import androidx.compose.ui.unit.IntOffset
39+
import androidx.compose.ui.unit.IntSize
40+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
41+
import kotlinx.coroutines.flow.Flow
42+
import ovh.plrapps.mapcompose.api.VisibleArea
43+
import ovh.plrapps.mapcompose.api.fullSize
44+
import ovh.plrapps.mapcompose.api.getLayoutSizeFlow
45+
import ovh.plrapps.mapcompose.ui.state.MapState
46+
import ovh.plrapps.mapcompose.utils.AngleDegree
47+
import ovh.plrapps.mapcompose.utils.Point
48+
import ru.spbu.depnav.data.model.Marker
49+
import ru.spbu.depnav.utils.map.LineSegment
50+
import ru.spbu.depnav.utils.map.bottom
51+
import ru.spbu.depnav.utils.map.centroid
52+
import ru.spbu.depnav.utils.map.contains
53+
import ru.spbu.depnav.utils.map.left
54+
import ru.spbu.depnav.utils.map.rectangularVisibleArea
55+
import ru.spbu.depnav.utils.map.right
56+
import ru.spbu.depnav.utils.map.rotation
57+
import ru.spbu.depnav.utils.map.top
58+
59+
/**
60+
* When the pin is outside of the map area visible on the screen, shows a pointer towards the pin.
61+
*
62+
* It is intended to be placed exactly over the map's composable.
63+
*/
64+
@Composable
65+
fun PinPointer(mapState: MapState, pin: Marker?) {
66+
var mapLayoutSizeFlow by remember { mutableStateOf<Flow<IntSize>?>(null) }
67+
LaunchedEffect(mapState) { mapLayoutSizeFlow = mapState.getLayoutSizeFlow() }
68+
val mapLayoutSize by mapLayoutSizeFlow?.collectAsStateWithLifecycle(IntSize.Zero) ?: return
69+
if (mapLayoutSize == IntSize.Zero) {
70+
return
71+
}
72+
73+
val pinSize = with(LocalDensity.current) { PIN_SIZE.roundToPx() }
74+
75+
val pinPoint = pin?.run { Point(x * mapState.fullSize.width, y * mapState.fullSize.height) }
76+
val visibleArea = mapState.rectangularVisibleArea(mapLayoutSize) // Area visible on the screen
77+
78+
val currentPointerPose =
79+
if (
80+
pinPoint != null &&
81+
!mapState.rectangularVisibleArea( // Area on which the pin is visible on the screen
82+
mapLayoutSize,
83+
leftPadding = pinSize / 2,
84+
rightPadding = pinSize / 2,
85+
bottomPadding = pinSize
86+
).contains(pinPoint)
87+
) {
88+
calculatePointerPose(visibleArea, pinPoint)
89+
} else {
90+
null // There is no pin or it is visible on the screen
91+
}
92+
93+
// Have to remember the latest non-null pointer pose to continue showing it while the exit
94+
// animation is still in progress
95+
var lastPointerPose by remember { mutableStateOf(PinPointerPose.Empty) }
96+
if (currentPointerPose != null) {
97+
lastPointerPose = currentPointerPose
98+
}
99+
100+
AnimatedVisibility(
101+
visible = currentPointerPose != null,
102+
modifier = Modifier.absoluteOffset { lastPointerPose.coordinates(mapLayoutSize, pinSize) },
103+
enter = fadeIn() + slideIn { lastPointerPose.slideAnimationOffset(it) } + scaleIn(),
104+
exit = fadeOut() + slideOut { lastPointerPose.slideAnimationOffset(it) } + scaleOut()
105+
) {
106+
// Cannot use mapState.rotation since it has a different pivot
107+
Pin(modifier = Modifier.rotate(lastPointerPose.direction - visibleArea.rotation()))
108+
}
109+
}
110+
111+
private data class PinPointerPose(
112+
val side: Side,
113+
val sideFraction: Float,
114+
val direction: AngleDegree
115+
) {
116+
enum class Side { LEFT, RIGHT, TOP, BOTTOM }
117+
118+
companion object {
119+
val Empty = PinPointerPose(Side.TOP, 0f, 0f)
120+
}
121+
122+
fun coordinates(boxSize: IntSize, pinSize: Int): IntOffset {
123+
return when (side) {
124+
Side.LEFT -> IntOffset(
125+
x = 0,
126+
y = (boxSize.height * sideFraction - pinSize / 2f)
127+
.toInt()
128+
.coerceIn(0, boxSize.height - pinSize)
129+
)
130+
Side.RIGHT -> IntOffset(
131+
x = (boxSize.width - pinSize).coerceAtLeast(0),
132+
y = (boxSize.height * sideFraction - pinSize / 2f)
133+
.toInt()
134+
.coerceIn(0, boxSize.height - pinSize)
135+
)
136+
Side.TOP -> IntOffset(
137+
x = (boxSize.width * sideFraction - pinSize / 2f)
138+
.toInt()
139+
.coerceIn(0, boxSize.width - pinSize),
140+
y = 0
141+
)
142+
Side.BOTTOM -> IntOffset(
143+
x = (boxSize.width * sideFraction - pinSize / 2f)
144+
.toInt()
145+
.coerceIn(0, boxSize.width - pinSize),
146+
y = (boxSize.height - pinSize).coerceAtLeast(0)
147+
)
148+
}
149+
}
150+
151+
fun slideAnimationOffset(pinSize: IntSize) = when (side) {
152+
Side.LEFT -> IntOffset(x = -pinSize.width, y = 0)
153+
Side.RIGHT -> IntOffset(x = pinSize.width, y = 0)
154+
Side.TOP -> IntOffset(x = 0, y = -pinSize.height)
155+
Side.BOTTOM -> IntOffset(x = 0, y = pinSize.height)
156+
}
157+
}
158+
159+
private fun calculatePointerPose(visibleArea: VisibleArea, pin: Point): PinPointerPose {
160+
val centroidPinSegment = LineSegment(visibleArea.centroid(), pin)
161+
val direction = centroidPinSegment.slope() - 90
162+
163+
visibleArea.top().fractionOfIntersectionWith(centroidPinSegment)?.let { fraction ->
164+
return PinPointerPose(PinPointerPose.Side.TOP, fraction, direction)
165+
}
166+
visibleArea.right().fractionOfIntersectionWith(centroidPinSegment)?.let { fraction ->
167+
return PinPointerPose(PinPointerPose.Side.RIGHT, fraction, direction)
168+
}
169+
visibleArea.bottom().fractionOfIntersectionWith(centroidPinSegment)?.let { fraction ->
170+
return PinPointerPose(PinPointerPose.Side.BOTTOM, fraction, direction)
171+
}
172+
visibleArea.left().fractionOfIntersectionWith(centroidPinSegment)?.let { fraction ->
173+
return PinPointerPose(PinPointerPose.Side.LEFT, fraction, direction)
174+
}
175+
176+
throw IllegalArgumentException("Pin lies inside the visible area")
177+
}

app/src/main/java/ru/spbu/depnav/ui/screen/MapScreen.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,12 @@ import ru.spbu.depnav.ui.component.MainMenuSheet
7979
import ru.spbu.depnav.ui.component.MapSearchBar
8080
import ru.spbu.depnav.ui.component.MarkerInfoLines
8181
import ru.spbu.depnav.ui.component.MarkerView
82+
import ru.spbu.depnav.ui.component.PinPointer
8283
import ru.spbu.depnav.ui.component.ZoomInHint
8384
import ru.spbu.depnav.ui.dialog.MapLegendDialog
8485
import ru.spbu.depnav.ui.dialog.SettingsDialog
8586
import ru.spbu.depnav.ui.theme.DEFAULT_PADDING
87+
import ru.spbu.depnav.ui.theme.ON_MAP_SURFACE_ALPHA
8688
import ru.spbu.depnav.ui.viewmodel.MapUiState
8789
import ru.spbu.depnav.ui.viewmodel.MapViewModel
8890
import ru.spbu.depnav.ui.viewmodel.SearchResults
@@ -178,6 +180,8 @@ private fun OnMapUi(
178180
) {
179181
CompositionLocalProvider(LocalAbsoluteTonalElevation provides 4.dp) {
180182
Box(modifier = Modifier.fillMaxSize()) {
183+
PinPointer(mapUiState.mapState, mapUiState.pinnedMarker?.marker)
184+
181185
AnimatedSearchBar(
182186
visible = mapUiState.showOnMapUi,
183187
mapTitle = mapUiState.mapTitle,
@@ -298,7 +302,8 @@ private fun BoxScope.AnimatedBottom(pinnedMarker: MarkerWithText?, showZoomInHin
298302
shape = MaterialTheme.shapes.large.copy(
299303
bottomStart = CornerSize(0),
300304
bottomEnd = CornerSize(0)
301-
)
305+
),
306+
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = ON_MAP_SURFACE_ALPHA)
302307
) {
303308
// Have to remember the latest pinned marker to continue showing it while the exit
304309
// animation is still in progress

app/src/main/java/ru/spbu/depnav/ui/theme/Theme.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ val DEFAULT_PADDING = 16.dp
9999
/** Alpha value applied to disabled elements. */
100100
const val DISABLED_ALPHA = 0.38f
101101

102+
/** Alpha value applied to surfaces comprising on-map UI. */
103+
const val ON_MAP_SURFACE_ALPHA = 0.9f
104+
102105
/** Theme of the application. */
103106
@Composable
104107
fun DepNavTheme(

app/src/main/java/ru/spbu/depnav/ui/viewmodel/MapViewModel.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,6 @@ class MapViewModel @Inject constructor(
277277
y = marker.y,
278278
zIndex = 1f,
279279
clickable = false,
280-
relativeOffset = Offset(-0.5f, -0.5f),
281280
clipShape = null
282281
) { Pin() }
283282
}

0 commit comments

Comments
 (0)