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
|
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()
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Ładowanie…
Reference in New Issue