android - 如何实现一个可旋转的可拖动图标 UI?
问题描述
所附图像是应用程序 UI 的要求 - 由一组需要旋转的图标组成,就像旧的旋转电话一样。圆圈上的四个图标可以用手指拖动以旋转所有图标(一起),当释放时,它们与最靠近底部的图标固定在一起,点击底部位置,选中它,下面的文本总结该部分. 即当 UI 没有被拖动时,它只能处于四个位置(钟面上的中午 12 点、下午 3 点、下午 6 点、晚上 9 点)。
我以前没有实现过这样的可拖动 UI。我最好怎么做?我应该尝试使用 MotionLayout,还是监视触摸事件,更改图标视图的旋转位置,然后在 up 事件上,将旋转动画设置为“点击”底部最近的图标?
解决方案
我记得 ConstraintLayout v1.1+ 有一个圆形位置约束,这使得动画非常简单。处理点击并不那么简单,因为我找不到任何方法让它们传递到可拖动覆盖视图下方的 ImageView,因此必须计算点击了哪一个。对于其他希望实现这样的东西的人,这里有一些代码(注意它使用 Android 数据绑定)。UI 布局负责屏幕大小并将图标大小调整为 View UI 的百分比。
此代码不支持甩动,也不支持旋转拨号“锁定”到特定位置,但两者都可以添加在拖动结束后开始的动画。
RotaryView.kt:
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.widget.ImageView
import androidx.constraintlayout.widget.ConstraintLayout
import timber.log.Timber
/**
* Displays a circle of icons that rotate and can be selected (if at the bottom position)
* or clicked
*/
class RotaryView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private var binding: ViewRotaryBinding = ViewRotaryBinding.inflate(LayoutInflater.from(context), this, true)
private var callback: RotaryListener? = null
fun setUp(callback: RotaryListener) {
this.callback = callback
callback.onNutritionSelected() // Default selection
binding.dragOverlay.setOnTouchListener(DragListener())
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
setConstraintRadius(binding.dashboardMind)
setConstraintRadius(binding.dashboardFitness)
setConstraintRadius(binding.dashboardNutrition)
setConstraintRadius(binding.dashboardVirtualWorld)
}
/// Private methods
private fun setConstraintRadius(view: View) {
val layoutParams = view.layoutParams as ConstraintLayout.LayoutParams
layoutParams.circleRadius = width / 3
view.layoutParams = layoutParams
}
private fun rotateDialer(angleDelta: Float) {
setIconAngle(binding.dashboardMind, angleDelta) { callback?.onMindSelected() }
setIconAngle(binding.dashboardFitness, angleDelta) { callback?.onFitnessSelected() }
setIconAngle(binding.dashboardNutrition, angleDelta) { callback?.onNutritionSelected() }
setIconAngle(binding.dashboardVirtualWorld, angleDelta) { callback?.onVirtualWorldSelected() }
}
private fun setIconAngle(imageView: ImageView, angleDelta: Float, showSummary: ()->Unit) {
val layoutParams = imageView.layoutParams as ConstraintLayout.LayoutParams
val newAngle = normaliseAngle(layoutParams.circleAngle.toInt() + angleDelta.toInt())
if (newAngle in 136..224) showSummary() // Bottom quadrant
layoutParams.circleAngle = newAngle.toFloat()
imageView.layoutParams = layoutParams
}
private fun handleClick(angle: Int) {
val clickAngle0to360 = normaliseAngle(90 - angle)
val layoutParams = binding.dashboardMind.layoutParams as ConstraintLayout.LayoutParams
val iconsAngle0to360 = normaliseAngle(layoutParams.circleAngle.toInt())
val correctedAngle = normaliseAngle(clickAngle0to360 - iconsAngle0to360)
when {
(correctedAngle > (360-45) || correctedAngle < 45) -> callback?.onMindClicked()
((45) .. (90 + 45)).contains(correctedAngle) -> callback?.onFitnessClicked()
((180 - 45) .. (180 + 45)).contains(correctedAngle) -> callback?.onNutritionClicked()
((270 - 45) .. (270 + 45)).contains(correctedAngle) -> callback?.onVirtualWorldClicked()
else -> Timber.e("Impossible state")
}
}
private fun normaliseAngle(angle: Int) : Int {
return (angle + 360).rem(360)
}
private inner class DragListener : OnTouchListener {
private var startAngle: Double = 0.toDouble()
private var shouldClick = true
override fun onTouch(v: View, event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
shouldClick = true
startAngle = getAngle(event.x.toDouble(), event.y.toDouble())
}
MotionEvent.ACTION_MOVE -> {
val currentAngle = getAngle(event.x.toDouble(), event.y.toDouble())
rotateDialer((startAngle - currentAngle).toFloat())
startAngle = currentAngle
shouldClick = false
v.performClick() // Just here to avoid IDE warnings
}
MotionEvent.ACTION_UP -> {
if (shouldClick) {
val angle = getAngle(event.x.toDouble(), event.y.toDouble())
handleClick(angle.toInt())
}
}
}
return true
}
private fun getAngle(xTouch: Double, yTouch: Double): Double {
val x = xTouch - width / 2.0
val y = height - yTouch - height / 2.0
return when (getQuadrant(x, y)) {
1 -> Math.asin(y / Math.hypot(x, y)) * 180 / Math.PI
2 -> 180 - Math.asin(y / Math.hypot(x, y)) * 180 / Math.PI
3 -> 180 + -1.0 * Math.asin(y / Math.hypot(x, y)) * 180.0 / Math.PI
4 -> 360 + Math.asin(y / Math.hypot(x, y)) * 180 / Math.PI
else -> 0.0
}
}
private fun getQuadrant(x: Double, y: Double): Int {
return if (x >= 0) {
if (y >= 0) 1 else 4
} else {
if (y >= 0) 2 else 3
}
}
}
interface RotaryListener {
fun onMindClicked()
fun onMindSelected()
fun onFitnessClicked()
fun onFitnessSelected()
fun onNutritionClicked()
fun onNutritionSelected()
fun onVirtualWorldClicked()
fun onVirtualWorldSelected()
}
}
view_rotary.xml:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<ImageView
android:id="@+id/dashboard_circle"
android:layout_width="0dp"
android:layout_height="0dp"
android:src="@drawable/ic_dashboard_circle"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintWidth_percent="0.9"
app:layout_constraintHeight_percent="0.9"
tools:ignore="ContentDescription"
/>
<ImageView
android:id="@+id/dashboard_mind"
android:layout_width="0dp"
android:layout_height="0dp"
android:src="@drawable/ic_dashboard_mind"
app:layout_constraintCircle="@+id/dashboard_circle"
app:layout_constraintCircleRadius="120dp"
app:layout_constraintCircleAngle="0"
app:layout_constraintWidth_percent="0.3"
app:layout_constraintHeight_percent="0.3"
android:contentDescription="@string/dash_board_mind_content_description"
/>
<ImageView
android:id="@+id/dashboard_virtual_world"
android:layout_width="0dp"
android:layout_height="0dp"
android:src="@drawable/ic_dashboard_virtual_world"
app:layout_constraintCircle="@+id/dashboard_circle"
app:layout_constraintCircleRadius="120dp"
app:layout_constraintCircleAngle="270"
app:layout_constraintWidth_percent="0.3"
app:layout_constraintHeight_percent="0.3"
android:contentDescription="@string/dashboard_virtual_world_content_description"
/>
<ImageView
android:id="@+id/dashboard_fitness"
android:layout_width="0dp"
android:layout_height="0dp"
android:src="@drawable/ic_dashboard_fitness"
app:layout_constraintCircle="@+id/dashboard_circle"
app:layout_constraintCircleRadius="120dp"
app:layout_constraintCircleAngle="90"
app:layout_constraintWidth_percent="0.3"
app:layout_constraintHeight_percent="0.3"
android:contentDescription="@string/dashboard_fitness_content_description"
/>
<ImageView
android:id="@+id/dashboard_nutrition"
android:layout_width="0dp"
android:layout_height="0dp"
android:src="@drawable/ic_dashboard_nutrition"
app:layout_constraintCircle="@+id/dashboard_circle"
app:layout_constraintCircleRadius="120dp"
app:layout_constraintCircleAngle="180"
app:layout_constraintWidth_percent="0.3"
app:layout_constraintHeight_percent="0.3"
android:contentDescription="@string/dashboard_nutrition_content_description"
/>
<View
android:id="@+id/dragOverlay"
android:layout_width="0dp"
android:layout_height="0dp"
android:clickable="true"
android:focusable="true"
app:layout_constraintStart_toStartOf="@id/dashboard_circle"
app:layout_constraintEnd_toEndOf="@id/dashboard_circle"
app:layout_constraintTop_toTopOf="@+id/dashboard_circle"
app:layout_constraintBottom_toBottomOf="@id/dashboard_circle"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
推荐阅读
- java - 尝试将时间戳转换为数据类型为 int 的时间
- reactjs - HTML
- 标记左对齐
- ab-initio - 如何在 EME 中为每次结账触发电子邮件 - Ab Initio
- angular - Firestore 事务 - 事务失败:TypeError: transaction.set(...).then 不是函数
- android - onbackpressed 在显示文件选择器中
- opencv - ffmpeg从jpgs到huffyuv视频的无损转换没有按预期工作
- python - 如何从另一个 Pandas Timeseries 创建 Pandas Dataframe 列?
- javascript - 如何在特定索引处添加到 JavaScript 计算
- javascript - 使用 Webpack 别名运行磁带
- reactjs - React 表单输入显示无法更改的数据