Long press node in map jumps to node in node list (#955)

* Fix scrolling to node and blinking

* Show node in list, instead of opening DM
pull/958/head
Davis 2024-04-06 05:36:01 -06:00 zatwierdzone przez GitHub
rodzic 80e9bbbe56
commit e887336da3
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
3 zmienionych plików z 87 dodań i 49 usunięć

Wyświetl plik

@ -1,5 +1,11 @@
package com.geeksville.mesh.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -15,6 +21,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
@ -47,8 +54,12 @@ fun NodeInfo(
distanceUnits: Int,
tempInFahrenheit: Boolean,
isIgnored: Boolean = false,
onClicked: () -> Unit = {}
onClicked: () -> Unit = {},
blinking: Boolean = false,
) {
val BLINK_DURATION = 250
val unknownShortName = stringResource(id = R.string.unknown_node_short_name)
val unknownLongName = stringResource(id = R.string.unknown_username)
@ -57,6 +68,18 @@ fun NodeInfo(
val distance = thisNodeInfo?.distanceStr(thatNodeInfo, distanceUnits)
val (textColor, nodeColor) = thatNodeInfo.colors
val highlight = Color(0x33FFFFFF)
val bgColor by animateColorAsState(
targetValue = if (blinking) highlight else Color.Transparent,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = BLINK_DURATION,
easing = FastOutSlowInEasing
),
repeatMode = RepeatMode.Reverse
)
)
Card(
modifier = Modifier
.fillMaxWidth()
@ -67,6 +90,7 @@ fun NodeInfo(
ConstraintLayout(
modifier = Modifier
.fillMaxWidth()
.background(bgColor)
.padding(8.dp)
) {
val (chip, dist, name, pos, alt, sats, batt, heard, sig, env) = createRefs()

Wyświetl plik

@ -1,25 +1,23 @@
package com.geeksville.mesh.ui
import android.animation.ValueAnimator
import android.content.Context
import android.content.res.ColorStateList
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.animation.LinearInterpolator
import androidx.appcompat.widget.PopupMenu
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.dp
import androidx.core.animation.doOnEnd
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope
@ -36,9 +34,10 @@ import com.geeksville.mesh.ui.theme.AppTheme
import com.geeksville.mesh.util.Exceptions
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
/**
* Workaround for RecyclerView bug throwing:
@ -71,26 +70,12 @@ class UsersFragment : ScreenFragment("Users"), Logging {
class ViewHolder(val composeView: ComposeView) : RecyclerView.ViewHolder(composeView) {
// TODO not working with compose changes
fun blink() {
val bg = composeView.backgroundTintList
ValueAnimator.ofArgb(
android.graphics.Color.parseColor("#00FFFFFF"),
android.graphics.Color.parseColor("#33FFFFFF")
).apply {
interpolator = LinearInterpolator()
startDelay = 500
duration = 250
repeatCount = 3
repeatMode = ValueAnimator.REVERSE
addUpdateListener {
composeView.backgroundTintList = ColorStateList.valueOf(it.animatedValue as Int)
}
start()
doOnEnd {
composeView.backgroundTintList = bg
}
}
var shouldBlink by mutableStateOf(false)
suspend fun blink() {
shouldBlink = true
delay(500)
shouldBlink = false
}
fun bind(
@ -109,7 +94,8 @@ class UsersFragment : ScreenFragment("Users"), Logging {
gpsFormat = gpsFormat,
distanceUnits = distanceUnits,
tempInFahrenheit = tempInFahrenheit,
onClicked = onChipClicked
onClicked = onChipClicked,
blinking = shouldBlink,
)
}
}
@ -295,10 +281,18 @@ class UsersFragment : ScreenFragment("Users"), Logging {
if (idx < 1) return@observe
lifecycleScope.launch {
binding.nodeListView.layoutManager?.smoothScrollToTop(idx)
val vh = binding.nodeListView.findViewHolderForLayoutPosition(idx)
(vh as? ViewHolder)?.blink()
model.focusUserNode(null)
with (binding.nodeListView.layoutManager as LinearLayoutManager) {
smoothScrollToTop(idx)
binding.nodeListView.awaitScrollStateIdle()
if (!isIndexAtTop(idx)) { // settle the scroll position
smoothScrollToTop(idx)
}
val vh = binding.nodeListView.findViewHolderForLayoutPosition(idx)
(vh as? ViewHolder)?.blink() ?: warn("viewholder wasn't there. May need to wait for it")
model.focusUserNode(null)
}
}
}
}
@ -311,8 +305,11 @@ class UsersFragment : ScreenFragment("Users"), Logging {
/**
* Scrolls the recycler view until the item at [position] is at the top of the view, then waits
* until the scrolling is finished.
* @param precision The time in milliseconds to wait between checks for the scroll state.
*/
private suspend fun RecyclerView.LayoutManager.smoothScrollToTop(position: Int) {
private suspend fun RecyclerView.LayoutManager.smoothScrollToTop(
position: Int, precision: Long = 100
) {
this.startSmoothScroll(
object : LinearSmoothScroller(requireContext()) {
override fun getVerticalSnapPreference(): Int {
@ -322,10 +319,9 @@ class UsersFragment : ScreenFragment("Users"), Logging {
targetPosition = position
}
)
withContext(Dispatchers.Default) {
while (this@smoothScrollToTop.isSmoothScrolling) {
// noop
}
while (isSmoothScrolling) {
delay(precision)
}
}
@ -348,4 +344,31 @@ class UsersFragment : ScreenFragment("Users"), Logging {
}
}
private suspend fun RecyclerView.awaitScrollStateIdle() = suspendCancellableCoroutine { continuation ->
if (scrollState == RecyclerView.SCROLL_STATE_IDLE) {
warn("RecyclerView scrollState is already idle")
continuation.resume(Unit)
return@suspendCancellableCoroutine
}
val scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
recyclerView.removeOnScrollListener(this)
continuation.resume(Unit)
}
}
}
addOnScrollListener(scrollListener)
continuation.invokeOnCancellation {
removeOnScrollListener(scrollListener)
}
}
fun LinearLayoutManager.isIndexAtTop(idx: Int): Boolean {
val first = findFirstVisibleItemPosition()
val firstVisible = findFirstCompletelyVisibleItemPosition()
return first == idx && firstVisible == idx
}
}

Wyświetl plik

@ -105,21 +105,12 @@ class MapFragment : ScreenFragment("Map Fragment"), Logging {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AppCompatTheme {
MapView(model, ::openDirectMessage)
MapView(model)
}
}
}
}
private fun openDirectMessage(node: NodeInfo) {
val user = node.user ?: return
model.setContactKey("${node.channel}${user.id}")
parentFragmentManager.beginTransaction()
.replace(R.id.mainActivityLayout, MessagesFragment())
.addToBackStack(null)
.commit()
}
}
@Composable
@ -135,7 +126,6 @@ private fun MapView.UpdateMarkers(
@Composable
fun MapView(
model: UIViewModel = viewModel(),
openDirectMessage: (NodeInfo) -> Unit = { },
) {
// UI Elements
@ -236,7 +226,8 @@ fun MapView(
icon = markerIcon
setOnLongClickListener {
openDirectMessage(node)
performHapticFeedback()
model.focusUserNode(node)
true
}
}