效果图
序言
在移动应用开发中,显示数据的方式多种多样,直观的图形展示常常能带给用户更好的体验。本文将介绍如何使用Flutter创建一个自定义三角形纬度评分控件,该控件可以通过动画展示评分的变化,让应用界面更加生动。
实现思路及步骤
- 定义控件属性:首先需要定义控件的基本属性,如宽度、高度、最大评分以及每个顶点的评分值。
- 自定义绘制:使用自定义View绘制三角形和评分三角形,并在顶点处绘制空心圆点。
- 实现动画效果:使用属性动画
ValueAnimator
来控制评分动画,使每个顶点的评分从0逐渐增加到对应的评分值。
代码实现
定义自定义属性和布局文件
在res/values/attrs.xml中定义自定义属性:
<declare-styleable name="TriangleRatingAnimView"> <attr name="maxRating" format="integer" /> <attr name="upRating" format="integer" /> <attr name="leftRating" format="integer" /> <attr name="rightRating" format="integer" /> <attr name="strokeColor" format="color" /> <attr name="strokeWidth" format="dimension" /> <attr name="ratingStrokeColor" format="color" /> <attr name="ratingStrokeWidth" format="dimension" /> </declare-styleable>
创建自定义View类
首先,创建一个自定义View类TriangleRatingAnimView,用于绘制三角形和动画效果。
package com.yxlh.androidxy.demo.ui.rating import android.animation.ValueAnimator import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Path import android.util.AttributeSet import android.util.TypedValue import android.view.View import androidx.core.content.withStyledAttributes import androidx.core.graphics.ColorUtils import androidx.interpolator.view.animation.LinearOutSlowInInterpolator import com.yxlh.androidxy.R fun Context.dpToPx(dp: Float): Float { return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics) } /** * 三角形评分控件 * https://github.com/yixiaolunhui/AndroidXY */ class TriangleRatingAnimView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : View(context, attrs, defStyleAttr) { var maxRating: Int = 5 set(value) { field = value invalidate() } var upRating: Int = 0 set(value) { field = value animateRating() } var leftRating: Int = 0 set(value) { field = value animateRating() } var rightRating: Int = 0 set(value) { field = value animateRating() } private var strokeColor: Int = Color.GRAY private var strokeWidth: Float = context.dpToPx(1.5f) private var ratingStrokeColor: Int = Color.RED private var ratingStrokeWidth: Float = context.dpToPx(2.5f) private var animatedUpRating = 0 private var animatedLeftRating = 0 private var animatedRightRating = 0 private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE color = strokeColor strokeWidth = this@TriangleRatingAnimView.strokeWidth } private val outerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE color = ratingStrokeColor strokeWidth = this@TriangleRatingAnimView.ratingStrokeWidth } private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL color = ColorUtils.setAlphaComponent(ratingStrokeColor, (0.3 * 255).toInt()) } private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE color = ratingStrokeColor strokeWidth = context.dpToPx(1.5f) } private val circleFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL color = Color.WHITE } init { context.withStyledAttributes(attrs, R.styleable.TriangleRatingAnimView) { maxRating = getInt(R.styleable.TriangleRatingAnimView_maxRating, 5) upRating = getInt(R.styleable.TriangleRatingAnimView_upRating, 0) leftRating = getInt(R.styleable.TriangleRatingAnimView_leftRating, 0) rightRating = getInt(R.styleable.TriangleRatingAnimView_rightRating, 0) strokeColor = getColor(R.styleable.TriangleRatingAnimView_strokeColor, Color.GRAY) strokeWidth = context.dpToPx(getDimension(R.styleable.TriangleRatingAnimView_strokeWidth, 2f)) ratingStrokeColor = getColor(R.styleable.TriangleRatingAnimView_ratingStrokeColor, Color.RED) ratingStrokeWidth = context.dpToPx(getDimension(R.styleable.TriangleRatingAnimView_ratingStrokeWidth, 4f)) } } private fun animateRating() { val animator = ValueAnimator.ofFloat(0f, 1f).apply { duration = 300 interpolator = LinearOutSlowInInterpolator() addUpdateListener { animation -> val animatedValue = animation.animatedValue as Float animatedUpRating = (upRating * animatedValue).toInt() animatedLeftRating = (leftRating * animatedValue).toInt() animatedRightRating = (rightRating * animatedValue).toInt() invalidate() } } animator.start() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val width = measuredWidth.toFloat() val height = measuredHeight.toFloat() val circleRadius = context.dpToPx(5f) val padding = circleRadius + context.dpToPx(2f) val p1 = width / 2 to padding val p2 = padding to height - padding val p3 = width - padding to height - padding // 绘制外部三角形 val path = Path().apply { moveTo(p1.first, p1.second) lineTo(p2.first, p2.second) lineTo(p3.first, p3.second) close() } canvas.drawPath(path, paint) val centroidX = (p1.first + p2.first + p3.first) / 3 val centroidY = (p1.second + p2.second + p3.second) / 3 // 绘制顶点到重心的连线 canvas.drawLine(p1.first, p1.second, centroidX, centroidY, paint) canvas.drawLine(p2.first, p2.second, centroidX, centroidY, paint) canvas.drawLine(p3.first, p3.second, centroidX, centroidY, paint) val dynamicP1 = centroidX + (p1.first - centroidX) * (animatedUpRating / maxRating.toFloat()) to centroidY + (p1.second - centroidY) * (animatedUpRating / maxRating.toFloat()) val dynamicP2 = centroidX + (p2.first - centroidX) * (animatedLeftRating / maxRating.toFloat()) to centroidY + (p2.second - centroidY) * (animatedLeftRating / maxRating.toFloat()) val dynamicP3 = centroidX + (p3.first - centroidX) * (animatedRightRating / maxRating.toFloat()) to centroidY + (p3.second - centroidY) * (animatedRightRating / maxRating.toFloat()) // 绘制内部动态三角形 val ratingPath = Path().apply { moveTo(dynamicP1.first, dynamicP1.second) lineTo(dynamicP2.first, dynamicP2.second) lineTo(dynamicP3.first, dynamicP3.second) close() } canvas.drawPath(ratingPath, outerPaint) canvas.drawPath(ratingPath, fillPaint) // 绘制动态点上的空心圆 canvas.drawCircle(dynamicP1.first, dynamicP1.second, circleRadius, circlePaint) canvas.drawCircle(dynamicP1.first, dynamicP1.second, circleRadius - context.dpToPx(1.5f), circleFillPaint) canvas.drawCircle(dynamicP2.first, dynamicP2.second, circleRadius, circlePaint) canvas.drawCircle(dynamicP2.first, dynamicP2.second, circleRadius - context.dpToPx(1.5f), circleFillPaint) canvas.drawCircle(dynamicP3.first, dynamicP3.second, circleRadius, circlePaint) canvas.drawCircle(dynamicP3.first, dynamicP3.second, circleRadius - context.dpToPx(1.5f), circleFillPaint) } }
定义Activity界面xml文件
在res/layout/activity_rating.xml中使用自定义View:
<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_gravity="center_horizontal" android:gravity="center" android:orientation="vertical"> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center"> <TextView android:id="@+id/upText" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="时间管理" android:textColor="@color/black" android:textSize="13sp" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> <com.yxlh.androidxy.demo.ui.rating.TriangleRatingAnimView android:id="@+id/triangleRatingAnimView" android:layout_width="300dp" android:layout_height="200dp" android:layout_centerInParent="true" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@+id/upText" app:leftRating="3" app:maxRating="10" app:ratingStrokeColor="@android:color/holo_red_dark" app:ratingStrokeWidth="4dp" app:rightRating="8" app:strokeColor="@android:color/darker_gray" app:strokeWidth="3dp" app:upRating="5" /> <TextView android:id="@+id/leftText" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="成本控制" android:textColor="@color/black" android:textSize="13sp" app:layout_constraintTop_toBottomOf="@+id/triangleRatingAnimView" app:layout_constraintLeft_toLeftOf="@+id/triangleRatingAnimView" /> <TextView android:id="@+id/rightText" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="质量保证" android:textColor="@color/black" android:textSize="13sp" app:layout_constraintTop_toBottomOf="@+id/triangleRatingAnimView" app:layout_constraintRight_toRightOf="@+id/triangleRatingAnimView" /> </androidx.constraintlayout.widget.ConstraintLayout> <Button android:id="@+id/randomizeButton" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_below="@id/triangleRatingAnimView" android:layout_centerHorizontal="true" android:layout_marginTop="20dp" android:text="更改数据" /> </androidx.appcompat.widget.LinearLayoutCompat>
定义RatingActivity
package com.yxlh.androidxy.demo.ui.rating import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import com.yxlh.androidxy.databinding.ActivityRatingBinding import kotlin.random.Random class RatingActivity : AppCompatActivity() { private var binding: ActivityRatingBinding? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityRatingBinding.inflate(layoutInflater) setContentView(binding?.root) binding?.randomizeButton?.setOnClickListener { randomizeRatings() } } private fun randomizeRatings() { val random = Random(System.currentTimeMillis()) val maxRating = 5 + random.nextInt(6) val upRating = 1 + random.nextInt(maxRating) val leftRating = 1 + random.nextInt(maxRating) val rightRating = 1 + random.nextInt(maxRating) binding?.triangleRatingAnimView?.apply { this.maxRating = maxRating this.upRating = upRating this.leftRating = leftRating this.rightRating = rightRating invalidate() } } }
通过以上步骤和代码,我们可以创建一个带动画效果的三角形纬度评分控件,使评分展示更加生动和直观。
详情可见:github.com/yixiaolunhui/AndroidXY