kopia lustrzana https://github.com/meshtastic/Meshtastic-Android
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 DMpull/958/head
rodzic
80e9bbbe56
commit
e887336da3
|
@ -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()
|
||||
|
|
|
@ -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,13 +281,21 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
if (idx < 1) return@observe
|
||||
|
||||
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)
|
||||
(vh as? ViewHolder)?.blink()
|
||||
(vh as? ViewHolder)?.blink() ?: warn("viewholder wasn't there. May need to wait for it")
|
||||
model.focusUserNode(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun 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
|
||||
* 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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Ładowanie…
Reference in New Issue