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 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.Box
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -15,6 +21,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -47,8 +54,12 @@ fun NodeInfo(
distanceUnits: Int, distanceUnits: Int,
tempInFahrenheit: Boolean, tempInFahrenheit: Boolean,
isIgnored: Boolean = false, 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 unknownShortName = stringResource(id = R.string.unknown_node_short_name)
val unknownLongName = stringResource(id = R.string.unknown_username) val unknownLongName = stringResource(id = R.string.unknown_username)
@ -57,6 +68,18 @@ fun NodeInfo(
val distance = thisNodeInfo?.distanceStr(thatNodeInfo, distanceUnits) val distance = thisNodeInfo?.distanceStr(thatNodeInfo, distanceUnits)
val (textColor, nodeColor) = thatNodeInfo.colors 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( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -67,6 +90,7 @@ fun NodeInfo(
ConstraintLayout( ConstraintLayout(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(bgColor)
.padding(8.dp) .padding(8.dp)
) { ) {
val (chip, dist, name, pos, alt, sats, batt, heard, sig, env) = createRefs() val (chip, dist, name, pos, alt, sats, batt, heard, sig, env) = createRefs()

Wyświetl plik

@ -1,25 +1,23 @@
package com.geeksville.mesh.ui package com.geeksville.mesh.ui
import android.animation.ValueAnimator
import android.content.Context import android.content.Context
import android.content.res.ColorStateList
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.LinearInterpolator
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.animation.doOnEnd
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -36,9 +34,10 @@ import com.geeksville.mesh.ui.theme.AppTheme
import com.geeksville.mesh.util.Exceptions import com.geeksville.mesh.util.Exceptions
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
/** /**
* Workaround for RecyclerView bug throwing: * Workaround for RecyclerView bug throwing:
@ -71,26 +70,12 @@ class UsersFragment : ScreenFragment("Users"), Logging {
class ViewHolder(val composeView: ComposeView) : RecyclerView.ViewHolder(composeView) { class ViewHolder(val composeView: ComposeView) : RecyclerView.ViewHolder(composeView) {
// TODO not working with compose changes var shouldBlink by mutableStateOf(false)
fun blink() {
val bg = composeView.backgroundTintList suspend fun blink() {
ValueAnimator.ofArgb( shouldBlink = true
android.graphics.Color.parseColor("#00FFFFFF"), delay(500)
android.graphics.Color.parseColor("#33FFFFFF") shouldBlink = false
).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
}
}
} }
fun bind( fun bind(
@ -109,7 +94,8 @@ class UsersFragment : ScreenFragment("Users"), Logging {
gpsFormat = gpsFormat, gpsFormat = gpsFormat,
distanceUnits = distanceUnits, distanceUnits = distanceUnits,
tempInFahrenheit = tempInFahrenheit, tempInFahrenheit = tempInFahrenheit,
onClicked = onChipClicked onClicked = onChipClicked,
blinking = shouldBlink,
) )
} }
} }
@ -295,13 +281,21 @@ class UsersFragment : ScreenFragment("Users"), Logging {
if (idx < 1) return@observe if (idx < 1) return@observe
lifecycleScope.launch { lifecycleScope.launch {
binding.nodeListView.layoutManager?.smoothScrollToTop(idx) 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) val vh = binding.nodeListView.findViewHolderForLayoutPosition(idx)
(vh as? ViewHolder)?.blink() (vh as? ViewHolder)?.blink() ?: warn("viewholder wasn't there. May need to wait for it")
model.focusUserNode(null) model.focusUserNode(null)
} }
} }
} }
}
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
@ -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 * Scrolls the recycler view until the item at [position] is at the top of the view, then waits
* until the scrolling is finished. * 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( this.startSmoothScroll(
object : LinearSmoothScroller(requireContext()) { object : LinearSmoothScroller(requireContext()) {
override fun getVerticalSnapPreference(): Int { override fun getVerticalSnapPreference(): Int {
@ -322,10 +319,9 @@ class UsersFragment : ScreenFragment("Users"), Logging {
targetPosition = position targetPosition = position
} }
) )
withContext(Dispatchers.Default) {
while (this@smoothScrollToTop.isSmoothScrolling) { while (isSmoothScrolling) {
// noop 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) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent { setContent {
AppCompatTheme { 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 @Composable
@ -135,7 +126,6 @@ private fun MapView.UpdateMarkers(
@Composable @Composable
fun MapView( fun MapView(
model: UIViewModel = viewModel(), model: UIViewModel = viewModel(),
openDirectMessage: (NodeInfo) -> Unit = { },
) { ) {
// UI Elements // UI Elements
@ -236,7 +226,8 @@ fun MapView(
icon = markerIcon icon = markerIcon
setOnLongClickListener { setOnLongClickListener {
openDirectMessage(node) performHapticFeedback()
model.focusUserNode(node)
true true
} }
} }