Reimplemented RadarView in Jetpack Compose

Arty Bishop 2023-03-06 18:30:28 +00:00
rodzic 442f13ad07
commit 1a67ca1227
4 zmienionych plików z 129 dodań i 423 usunięć

Wyświetl plik

@ -12,6 +12,6 @@ class ExampleInstrumentedTest {
@Test
fun useAppContext() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.rtbishop.bbctestapp", appContext.packageName)
assertEquals("com.rtbishop.look4sat", appContext.packageName)
}
}

Wyświetl plik

@ -9,19 +9,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import com.rtbishop.look4sat.R
import com.rtbishop.look4sat.domain.model.SatRadio
import timber.log.Timber
@Composable
fun RadarScreen(navController: NavController) {
@ -65,19 +62,6 @@ fun RadarScreen(navController: NavController) {
ElevatedCard(modifier = Modifier
.fillMaxSize()
.weight(1f)) {
// val context = LocalContext.current
// AndroidView({ RadarView(context) }, modifier = Modifier.fillMaxSize()) { radarView ->
// Timber.d("Radar view recomposition")
// radarView.setShowAim(true)
// radarView.setScanning(true)
// radarData.value?.let {
// radarView.setPosition(it.satPos)
// radarView.setPositions(it.satTrack)
// }
// orientation.value?.let {
// radarView.setOrientation(it.first, it.second, it.first)
// }
// }
radarData.value?.let { data ->
orientation.value?.let { triple ->
RadarViewCompose(

Wyświetl plik

@ -1,265 +1,142 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2022 Arty Bishop (bishop.arty@gmail.com)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalTextApi::class)
package com.rtbishop.look4sat.presentation.radarScreen
import android.content.Context
import android.graphics.*
import android.view.View
import androidx.core.content.ContextCompat
import com.rtbishop.look4sat.R
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.*
import androidx.compose.ui.text.*
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.domain.predict.PI_2
import com.rtbishop.look4sat.domain.predict.SatPos
import com.rtbishop.look4sat.domain.predict.TWO_PI
import com.rtbishop.look4sat.utility.toRadians
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
class RadarView(context: Context) : View(context) {
private val defaultColor = ContextCompat.getColor(context, R.color.accent)
private val scale = resources.displayMetrics.density
private val strokeSize = scale * 2f
private var position: SatPos? = null
private var positions: List<SatPos> = emptyList()
private var radarColor = ContextCompat.getColor(context, R.color.textMain)
private var radarCircleNum = 3
private var radarPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = radarColor
style = Paint.Style.STROKE
strokeWidth = strokeSize
}
private var shouldShowSweep = true
private var sweepColor = defaultColor
private var sweepDegrees = 0f
private var sweepSpeed = 15.0f
private var sweepPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = sweepColor
}
private var shouldShowBeacons = true
private var beaconColor = defaultColor
private var beaconSize = scale * 7f
private var beaconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = beaconColor
style = Paint.Style.FILL
}
private var radarTextSize = scale * 16f
private var radarTextColor = defaultColor
private var radarTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = radarTextColor
textSize = radarTextSize
}
private var isTrackCreated = false
private val trackPath: Path = Path()
private var trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
style = Paint.Style.STROKE
strokeWidth = strokeSize
}
private val arrowPath = Path()
private var arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.accent)
style = Paint.Style.FILL
strokeWidth = strokeSize
}
private var shouldShowAim = true
private var aimColor = Color.RED
private var aimPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = aimColor
style = Paint.Style.STROKE
strokeWidth = strokeSize
}
private var shouldRotateRadar = true
private var azimuth: Float = 0f
private var pitch: Float = 0f
private var roll: Float = 0f
fun setScanning(isScanning: Boolean) {
shouldShowSweep = isScanning
}
fun setPosition(position: SatPos) {
this.position = position
}
fun setPositions(positions: List<SatPos>) {
this.positions = positions
}
fun setShowAim(showAim: Boolean) {
shouldShowAim = showAim
}
fun setOrientation(azimuth: Float, pitch: Float, roll: Float) {
this.azimuth = azimuth
this.pitch = pitch
this.roll = roll
if (shouldRotateRadar) {
this.rotation = -azimuth
@Composable
fun RadarViewCompose(item: SatPos, items: List<SatPos>, orientation: Triple<Float, Float, Float>) {
val radarColor = Color(0xFFDCDCDC)
val primaryColor = Color(0xFFFFE082)
val secondaryColor = Color(0xFFDC0000)
val strokeWidth = 4f
val measurer = rememberTextMeasurer()
val sweepDegrees = remember { mutableStateOf(0f) }
val trackCreated = remember { mutableStateOf(false) }
val trackPath = remember { mutableStateOf(Path()) }
val trackEffect = remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
Canvas(modifier = Modifier.fillMaxSize()) {
val radius = (size.minDimension / 2f) * 0.95f
if (!trackCreated.value) {
trackPath.value = createTrackPath(items, radius)
trackEffect.value = createTrackEffect(trackPath.value)
trackCreated.value = true
}
}
override fun onDraw(canvas: Canvas) {
val radarWidth = width - paddingLeft - paddingRight
val radarHeight = height - paddingTop - paddingBottom
val radarRadius = min(width, height) * 0.48f
val cx = paddingLeft + radarWidth / 2f
val cy = paddingTop + radarHeight / 2f
if (positions.isNotEmpty() && !isTrackCreated) {
createPassTrajectory(radarRadius)
createPassTrajectoryArrow()
isTrackCreated = true
}
canvas.drawColor(ContextCompat.getColor(context, R.color.cardRegular))
drawRadarCircle(canvas, cx, cy, radarRadius)
drawRadarCross(canvas, cx, cy, radarRadius)
drawRadarText(canvas, cx, radarRadius)
canvas.translate(cx, cy)
if (positions.isNotEmpty()) {
canvas.drawPath(trackPath, trackPaint)
canvas.drawPath(trackPath, arrowPaint)
}
if (shouldShowBeacons) {
drawSatellite(canvas, radarRadius)
}
if (shouldShowAim) {
drawCrosshair(canvas, azimuth, pitch, radarRadius)
}
if (shouldShowSweep) {
canvas.translate(-cx, -cy)
drawRadarSweep(canvas, cx, cy, radarRadius)
sweepDegrees = (sweepDegrees + 360 / sweepSpeed / 60) % 360
}
invalidate()
}
private fun drawRadarCircle(canvas: Canvas, cx: Float, cy: Float, radius: Float) {
for (i in 0 until radarCircleNum) {
canvas.drawCircle(cx, cy, radius - radius / radarCircleNum * i, radarPaint)
}
}
private fun drawRadarCross(canvas: Canvas, cx: Float, cy: Float, radius: Float) {
canvas.drawLine(cx - radius, cy, cx + radius, cy, radarPaint)
canvas.drawLine(cx, cy - radius, cx, cy + radius, radarPaint)
}
private fun drawRadarText(canvas: Canvas, cx: Float, radius: Float) {
for (i in 0 until radarCircleNum) {
val degText = " ${(90 / radarCircleNum) * (radarCircleNum - i)}°"
canvas.drawText(degText, cx, radius - radius / radarCircleNum * i, radarTextPaint)
}
}
private fun drawSatellite(canvas: Canvas, radarRadius: Float) {
position?.let { satPos ->
if (satPos.elevation > 0) {
val satX = sph2CartX(satPos.azimuth, satPos.elevation, radarRadius.toDouble())
val satY = sph2CartY(satPos.azimuth, satPos.elevation, radarRadius.toDouble())
canvas.drawCircle(satX, -satY, beaconSize, beaconPaint)
rotate(-orientation.first) {
drawSweep(center, sweepDegrees.value, radius, primaryColor)
drawRadar(radius, radarColor, strokeWidth, 3)
drawInfo(radius, primaryColor, measurer, 3)
translate(center.x, center.y) {
drawTrack(trackPath.value, trackEffect.value, secondaryColor, primaryColor)
if (item.elevation > 0) drawPosition(item, radius, primaryColor)
drawAim(orientation.first, orientation.second, radius, strokeWidth, secondaryColor)
}
sweepDegrees.value = (sweepDegrees.value + 360 / 12.0f / 60) % 360
}
}
private fun drawCrosshair(canvas: Canvas, azimuth: Float, pitch: Float, radarRadius: Float) {
val azimuthRad = azimuth.toDouble().toRadians()
val tmpElevation = pitch.toDouble().toRadians()
val elevationRad = if (tmpElevation > 0.0) 0.0 else tmpElevation
val crossX = sph2CartX(azimuthRad, -elevationRad, radarRadius.toDouble())
val crossY = sph2CartY(azimuthRad, -elevationRad, radarRadius.toDouble())
canvas.drawLine(crossX - radarTextSize, -crossY, crossX + radarTextSize, -crossY, aimPaint)
canvas.drawLine(crossX, -crossY - radarTextSize, crossX, -crossY + radarTextSize, aimPaint)
canvas.drawCircle(crossX, -crossY, radarTextSize / 2, aimPaint)
}
private fun sph2CartX(azimuth: Double, elevation: Double, r: Double): Float {
val radius = r * (PI_2 - elevation) / PI_2
return (radius * cos(PI_2 - azimuth)).toFloat()
}
private fun sph2CartY(azimuth: Double, elevation: Double, r: Double): Float {
val radius = r * (PI_2 - elevation) / PI_2
return (radius * sin(PI_2 - azimuth)).toFloat()
}
private fun createPassTrajectory(radarRadius: Float) {
positions.forEachIndexed { index, satPos ->
val passX = sph2CartX(satPos.azimuth, satPos.elevation, radarRadius.toDouble())
val passY = sph2CartY(satPos.azimuth, satPos.elevation, radarRadius.toDouble())
if (index == 0) {
trackPath.moveTo(passX, -passY)
} else {
trackPath.lineTo(passX, -passY)
}
}
}
private fun createPassTrajectoryArrow() {
val radius = beaconSize
val sides = 3
val angle = TWO_PI / sides
arrowPath.moveTo((radius * cos(angle)).toFloat(), (radius * sin(angle)).toFloat())
for (i in 1 until sides) {
val x = (radius * cos(angle - angle * i)).toFloat()
val y = (radius * sin(angle - angle * i)).toFloat()
arrowPath.lineTo(x, y)
}
arrowPath.close()
val trackLength = PathMeasure(trackPath, false).length
val quarter = trackLength / 4f
val center = trackLength / 2f
val effect = PathDashPathEffect(arrowPath, center, quarter, PathDashPathEffect.Style.ROTATE)
arrowPaint.pathEffect = effect
}
private fun drawRadarSweep(canvas: Canvas, cx: Float, cy: Float, radius: Float) {
val sweepGradient = SweepGradient(
cx,
cy,
intArrayOf(
Color.TRANSPARENT,
changeAlpha(sweepColor, 0),
changeAlpha(sweepColor, 164),
changeAlpha(sweepColor, 255),
changeAlpha(sweepColor, 255)
),
floatArrayOf(0.0f, 0.55f, 0.996f, 0.999f, 1f)
)
sweepPaint.shader = sweepGradient
canvas.rotate(-90 + sweepDegrees, cx, cy)
canvas.drawCircle(cx, cy, radius, sweepPaint)
}
private fun changeAlpha(color: Int, alpha: Int): Int {
val red = Color.red(color)
val green = Color.green(color)
val blue = Color.blue(color)
return Color.argb(alpha, red, green, blue)
}
}
private fun DrawScope.drawRadar(radius: Float, color: Color, width: Float, circles: Int) {
for (i in 0 until circles) {
val circleRadius = radius - radius / circles.toFloat() * i.toFloat()
drawCircle(color, circleRadius, style = Stroke(width))
}
drawLine(color, center.copy(x = center.x - radius), center.copy(x = center.x + radius), width)
drawLine(color, center.copy(y = center.y - radius), center.copy(y = center.y + radius), width)
}
private fun DrawScope.drawInfo(radius: Float, color: Color, measurer: TextMeasurer, circles: Int) {
for (i in 0 until circles) {
val textY = (radius - radius / circles * i) - 32f
val textDeg = " ${(90 / circles) * (circles - i)}°"
drawText(measurer, textDeg, center.copy(y = textY), style = TextStyle(color, 15.sp))
}
}
private fun DrawScope.drawTrack(path: Path, effect: PathEffect, color: Color, effectColor: Color) {
drawPath(path, color, style = Stroke(4f))
drawPath(path, effectColor, style = Stroke(pathEffect = effect))
}
private fun DrawScope.drawPosition(item: SatPos, radius: Float, color: Color) {
val satX = sph2CartX(item.azimuth, item.elevation, radius.toDouble())
val satY = sph2CartY(item.azimuth, item.elevation, radius.toDouble())
drawCircle(color, 18f, center.copy(satX, -satY))
}
private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, width: Float, color: Color) {
val size = 36f
val azimRadians = azim.toDouble().toRadians()
val tempElevRadians = elev.toDouble().toRadians()
val elevRadians = if (tempElevRadians > 0.0) 0.0 else tempElevRadians
val aimX = sph2CartX(azimRadians, -elevRadians, radius.toDouble())
val aimY = sph2CartY(azimRadians, -elevRadians, radius.toDouble())
try {
drawLine(color, center.copy(aimX - size, -aimY), center.copy(aimX + size, -aimY), width)
drawLine(color, center.copy(aimX, -aimY - size), center.copy(aimX, -aimY + size), width)
drawCircle(color, size / 2, center.copy(aimX, -aimY), style = Stroke(width))
} catch (exception: Exception) {
// Timber.d(exception)
}
}
private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, color: Color) {
val colors = listOf(Color.Transparent, Color(0x80FFE082), color)
val colorStops = listOf(0.64f, 0.995f, 1f)
val brush = ShaderBrush(SweepGradientShader(center, colors, colorStops))
rotate(-90 + degrees, center) { drawCircle(brush, radius, style = Fill) }
}
private fun createTrackPath(positions: List<SatPos>, radius: Float): Path {
val trackPath = Path()
positions.forEachIndexed { index, satPos ->
val passX = sph2CartX(satPos.azimuth, satPos.elevation, radius.toDouble())
val passY = sph2CartY(satPos.azimuth, satPos.elevation, radius.toDouble())
if (index == 0) trackPath.moveTo(passX, -passY) else trackPath.lineTo(passX, -passY)
}
return trackPath
}
private fun createTrackEffect(trackPath: Path): PathEffect {
val shape = Path()
val shapeSides = 3
val shapeRadius = 18f
val angle = TWO_PI / shapeSides
shape.moveTo((shapeRadius * cos(angle)).toFloat(), (shapeRadius * sin(angle)).toFloat())
for (i in 1 until shapeSides) {
val x = (shapeRadius * cos(angle - angle * i)).toFloat()
val y = (shapeRadius * sin(angle - angle * i)).toFloat()
shape.lineTo(x, y)
}
shape.close()
val trackLength = PathMeasure().apply { setPath(trackPath, false) }.length
val advance = trackLength / 2f
val phase = trackLength / 4f
return PathEffect.stampedPathEffect(shape, advance, phase, StampedPathEffectStyle.Rotate)
}
private fun sph2CartX(azim: Double, elev: Double, r: Double): Float {
val radius = r * (PI_2 - elev) / PI_2
return (radius * cos(PI_2 - azim)).toFloat()
}
private fun sph2CartY(azim: Double, elev: Double, r: Double): Float {
val radius = r * (PI_2 - elev) / PI_2
return (radius * sin(PI_2 - azim)).toFloat()
}

Wyświetl plik

@ -1,155 +0,0 @@
@file:OptIn(ExperimentalTextApi::class)
package com.rtbishop.look4sat.presentation.radarScreen
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.*
import androidx.compose.ui.text.*
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.domain.predict.PI_2
import com.rtbishop.look4sat.domain.predict.SatPos
import com.rtbishop.look4sat.domain.predict.TWO_PI
import com.rtbishop.look4sat.utility.toRadians
import kotlin.math.cos
import kotlin.math.sin
@Composable
fun RadarViewCompose(item: SatPos, items: List<SatPos>, orientation: Triple<Float, Float, Float>) {
val measurer = rememberTextMeasurer()
val newTrackPath = remember { mutableStateOf(Path()) }
val newArrowPath = remember { mutableStateOf(Path()) }
var isTrackCreated = false
val yellowColor = Color(0xFFFFE082)
val whiteColor = Color(0xCCFFFFFF)
val redColor = Color(0xFFB71C1C)
val strokeWidth = 4f
Canvas(modifier = Modifier.fillMaxSize()) {
val radius = (size.minDimension / 2f) * 0.95f
if (items.isNotEmpty() && !isTrackCreated) {
newTrackPath.value = createPassTrajectory(items, radius)
newArrowPath.value = createPassTrajectoryArrow()
isTrackCreated = true
}
rotate(-orientation.first) {
drawRadarCirle(radius, whiteColor, strokeWidth)
drawRadarCross(radius, whiteColor, strokeWidth)
drawRadarText(radius, yellowColor, measurer)
translate(center.x, center.y) {
if (items.isNotEmpty()) {
val effect = createPathEffect(newTrackPath.value, newArrowPath.value)
drawPath(newTrackPath.value, redColor, style = Stroke(4f))
drawPath(newTrackPath.value, yellowColor, style = Stroke(pathEffect = effect))
}
drawSatellite(item, radius, yellowColor)
drawCrosshair(orientation.first, orientation.second, radius, strokeWidth)
}
}
}
}
private fun DrawScope.drawRadarCirle(radius: Float, color: Color, width: Float, circles: Int = 3) {
for (i in 0 until circles) {
val circleRadius = radius - radius / circles.toFloat() * i.toFloat()
drawCircle(color, circleRadius, style = Stroke(width))
}
}
private fun DrawScope.drawRadarCross(radius: Float, color: Color, width: Float) {
drawLine(color, center, center.copy(x = center.x - radius), strokeWidth = width)
drawLine(color, center, center.copy(x = center.x + radius), strokeWidth = width)
drawLine(color, center, center.copy(y = center.y - radius), strokeWidth = width)
drawLine(color, center, center.copy(y = center.y + radius), strokeWidth = width)
}
private fun DrawScope.drawRadarText(
radius: Float, color: Color, measurer: TextMeasurer, circles: Int = 3
) {
for (i in 0..circles) {
val textY = (radius - radius / circles * i) + 25f
val textDeg = " ${(90 / circles) * (circles - i)}°"
drawText(measurer, textDeg, center.copy(y = textY), style = TextStyle(color, 16.sp))
}
}
private fun DrawScope.drawSatellite(item: SatPos, radius: Float, color: Color) {
if (item.elevation > 0) {
val satX = sph2CartX(item.azimuth, item.elevation, radius.toDouble())
val satY = sph2CartY(item.azimuth, item.elevation, radius.toDouble())
drawCircle(color, 16f, center.copy(satX, -satY))
}
}
private fun DrawScope.drawCrosshair(azimuth: Float, elevation: Float, radius: Float, width: Float) {
val redColor = Color(0xFFFF0000)
val size = 36f
val azimuthRad = azimuth.toDouble().toRadians()
val tmpElevation = elevation.toDouble().toRadians()
val elevationRad = if (tmpElevation > 0.0) 0.0 else tmpElevation
val crossX = sph2CartX(azimuthRad, -elevationRad, radius.toDouble())
val crossY = sph2CartY(azimuthRad, -elevationRad, radius.toDouble())
drawLine(
redColor,
center.copy(crossX - size, -crossY),
center.copy(crossX + size, -crossY),
strokeWidth = width
)
drawLine(
redColor,
center.copy(crossX, -crossY - size),
center.copy(crossX, -crossY + size),
strokeWidth = width
)
drawCircle(redColor, size / 2, center.copy(crossX, -crossY), style = Stroke(width))
}
private fun createPassTrajectory(positions: List<SatPos>, radarRadius: Float): Path {
val trackPath = Path()
positions.forEachIndexed { index, satPos ->
val passX = sph2CartX(satPos.azimuth, satPos.elevation, radarRadius.toDouble())
val passY = sph2CartY(satPos.azimuth, satPos.elevation, radarRadius.toDouble())
if (index == 0) {
trackPath.moveTo(passX, -passY)
} else {
trackPath.lineTo(passX, -passY)
}
}
return trackPath
}
private fun createPassTrajectoryArrow(): Path {
val arrowPath = Path()
val radius = 24f
val sides = 3
val angle = TWO_PI / sides
arrowPath.moveTo((radius * cos(angle)).toFloat(), (radius * sin(angle)).toFloat())
for (i in 1 until sides) {
val x = (radius * cos(angle - angle * i)).toFloat()
val y = (radius * sin(angle - angle * i)).toFloat()
arrowPath.lineTo(x, y)
}
arrowPath.close()
return arrowPath
}
private fun createPathEffect(trackPath: Path, arrowPath: Path): PathEffect {
val trackLength = PathMeasure().apply { setPath(trackPath, false) }.length
val quarter = trackLength / 4f
val center = trackLength / 2f
return PathEffect.stampedPathEffect(arrowPath, center, quarter, StampedPathEffectStyle.Rotate)
}
private fun sph2CartX(azimuth: Double, elevation: Double, r: Double): Float {
val radius = r * (PI_2 - elevation) / PI_2
return (radius * cos(PI_2 - azimuth)).toFloat()
}
private fun sph2CartY(azimuth: Double, elevation: Double, r: Double): Float {
val radius = r * (PI_2 - elevation) / PI_2
return (radius * sin(PI_2 - azimuth)).toFloat()
}