android - 创建从 iOS (Swift) 到 Android(Kotlin) 的 Needle GaugeView

标签 android ios swift kotlin android-custom-view

需要帮助将 iOS 中用 Swift 编写的仪 TableView 转换为 Android 中用 Kotlin 编写的自定义 View 。 Video & assets enter image description here

import UIKit

class GaugeView: UIView {

    var outerBezelColor = UIColor.gray20!
    var outerBezelWidth: CGFloat = 2
    var innerBezelColor = UIColor.baseWhite
    var innerBezelWidth: CGFloat = 5
    var insideColor = UIColor.baseWhite

    var segmentWidth: CGFloat = 0
    var segmentColors = [UIColor.gray20!]

    var totalAngle: CGFloat = 270
    var rotation: CGFloat = -135

    let mainBg = UIImageView()
    
    var needleColor = UIColor.clear
    var needleWidth: CGFloat = 23
    let needle = UIView()
    let polygon = UIImageView()
    
    let valueLabel = UILabel()
    var valueFont = UIFont(name: "PlusJakartaSans-ExtraBold", size: 32)
    var valueColor = UIColor.gray80
    
    let statusLabel = UILabel()
    var statusFont = UIFont(name: "PlusJakartaSans-Regular", size: 16)
    var statusColor = UIColor.gray70
    
    var value: Int = 0 {
        didSet {
            // update the value label to show the exact number
            valueLabel.text = String(value)

            // figure out where the needle is, between 0 and 1
            let needlePosition = CGFloat(value) / 100

            // create a lerp from the start angle (rotation) through to the end angle (rotation + totalAngle)
            let lerpFrom = rotation
            let lerpTo = rotation + totalAngle

            // lerp from the start to the end position, based on the needle's position
            let needleRotation = lerpFrom + (lerpTo - lerpFrom) * needlePosition
            needle.transform = CGAffineTransform(rotationAngle: deg2rad(needleRotation))
        }
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setUp()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setUp()
    }
    
    func setUp() {
        needle.backgroundColor = needleColor
        needle.translatesAutoresizingMaskIntoConstraints = false

        // make the needle a third of our height
        needle.bounds = CGRect(x: 0, y: 0, width: needleWidth, height: bounds.height / 3)
        
        mainBg.bounds = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.size.width/1.2, height: UIScreen.main.bounds.size.width/1.2)
        mainBg.image = UIImage(named: "credit-score-meter")
        mainBg.contentMode = .scaleAspectFit
        mainBg.center = CGPoint(x: bounds.midX, y: bounds.midY)
        
        polygon.bounds = CGRect(x: 0, y: 0, width: 23, height: 23)
        polygon.image = UIImage(named: "polygon")
        polygon.center = CGPoint(x: needle.bounds.midX, y: 0)

        // align it so that it is positioned and rotated from the bottom center
        needle.layer.anchorPoint = CGPoint(x: 0.5, y: 1)

        // now center the needle over our center point
        needle.center = CGPoint(x: bounds.midX, y: bounds.midY)
        addSubview(mainBg)
        addSubview(needle)
        needle.addSubview(polygon)
        
        valueLabel.font = valueFont
        valueLabel.text = "0"
        valueLabel.textColor = valueColor
        valueLabel.translatesAutoresizingMaskIntoConstraints = false
        addSubview(valueLabel)
        
        statusLabel.font = statusFont
        statusLabel.text = "VERY GOOD"
        statusLabel.textColor = statusColor
        statusLabel.translatesAutoresizingMaskIntoConstraints = false
        addSubview(statusLabel)

        NSLayoutConstraint.activate([
            valueLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
            valueLabel.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -20)
        ])
        
        NSLayoutConstraint.activate([
            statusLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
            statusLabel.centerYAnchor.constraint(equalTo: centerYAnchor, constant: 20)
        ])
    }
    
    override func draw(_ rect: CGRect) {
        guard let ctx = UIGraphicsGetCurrentContext() else { return }
        drawSegments(in: rect, context: ctx)
    }
    
    func deg2rad(_ number: CGFloat) -> CGFloat {
        return number * .pi / 180
    }
    
    func drawSegments(in rect: CGRect, context ctx: CGContext) {
        // 1: Save the current drawing configuration
        ctx.saveGState()

        // 2: Move to the center of our drawing rectangle and rotate so that we're pointing at the start of the first segment
        ctx.translateBy(x: rect.midX, y: rect.midY)
        ctx.rotate(by: deg2rad(rotation) - (.pi / 2))

        // 3: Set up the user's line width
        ctx.setLineWidth(segmentWidth)

        // 4: Calculate the size of each segment in the total gauge
        let segmentAngle = deg2rad(totalAngle / CGFloat(segmentColors.count))

        // 5: Calculate how wide the segment arcs should be
        let segmentRadius = (((rect.width - segmentWidth) / 2) - outerBezelWidth) - innerBezelWidth

        // 6: Draw each segment
        for (index, segment) in segmentColors.enumerated() {
            // figure out where the segment starts in our arc
            let start = CGFloat(index) * segmentAngle

            // activate its color
            segment.set()

            // add a path for the segment
            ctx.addArc(center: .zero, radius: segmentRadius, startAngle: start, endAngle: start + segmentAngle, clockwise: false)

            // and stroke it using the activated color
            ctx.drawPath(using: .stroke)
        }

        // 7: Reset the graphics state
        ctx.restoreGState()
    }
}

我想做的是 使用 FrameLayout 在其上绘制小灰线段。我将尝试添加的下一件事是 ImageView 和针 View ,但我不确定如何适本地旋转它。

class CreditScore(context: Context, attrs: AttributeSet): FrameLayout(context, attrs) {

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
    }
}

最佳答案

要在 Android 中实现具有类似行为的上述 Needle GaugeView,您需要一个自定义 ViewGroup 才能为背景图像和针添加 subview ,如 RelativeLayout > 或您在问题中建议的 FrameLayout 。基于您的快速代码,我在 kotlin 中实现了一个类似的 GaugeView,它从 RelativeLayout 扩展而来。

1.首先创建自定义的 GaugeView,它从 RelativeLayout 扩展而来,如下所示:

class GaugeView : RelativeLayout {

    private lateinit var mainBg: ImageView
    private lateinit var needle: RelativeLayout
    private lateinit var polygon: ImageView
    private lateinit var labelsLL: LinearLayout
    private lateinit var valueLabel: TextView
    private lateinit var statusLabel: TextView

    var outerBezelColor: Int = Color.GRAY
    var innerBezelColor: Int = Color.WHITE
    var insideColor: Int = Color.WHITE
    var needleColor: Int = Color.TRANSPARENT
    var valueColor: Int = Color.DKGRAY
    var statusColor: Int = Color.DKGRAY

    var outerBezelWidth = 2f
    var innerBezelWidth = 5f
    var segmentWidth = 0f
    var needleWidth = 23f
    var segmentColors = intArrayOf(Color.GRAY)

    var totalAngle = 270f
    var rotationAngle = -135f

    var path: Path = Path()
    var paint: Paint = Paint()
    var radiusPathRectF = RectF()
    var w = 0
    var h = 0

    constructor(context: Context?) : super(context) {
        setup()
    }

    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
        setup()
    }

    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        setup()
    }

    private fun setup() {

        //create the needle RelativeLayout ViewGroup
        needle = RelativeLayout(context)
        needle.setBackgroundColor(needleColor)

        //create the mainBg ImageView
        mainBg = ImageView(context)
        mainBg.setImageResource(R.drawable.credit_score_meter)
        mainBg.setAdjustViewBounds(true)
        mainBg.setScaleType(ImageView.ScaleType.CENTER_INSIDE)

        //create the polygon ImageView
        polygon = ImageView(context)
        polygon.setImageResource(R.drawable.ic_needle)
        polygon.setAdjustViewBounds(true)
        polygon.setScaleType(ImageView.ScaleType.CENTER_INSIDE)

        //add the mainBg and needle as subviews and polygon as a subview of needle
        addView(mainBg)
        addView(needle)
        needle.addView(polygon)

        //create a Vertical LinearLayout ViewGroup to add the valueLabel and statusLabel as subviews
        labelsLL = LinearLayout(context)
        labelsLL.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)
        labelsLL.orientation = LinearLayout.VERTICAL
        addView(labelsLL)

        //create the valueLabel TextView and add it as a subview of labelsLL
        valueLabel = TextView(context)
        valueLabel.text = "0"
        valueLabel.setTextColor(valueColor)
        valueLabel.gravity = Gravity.CENTER
        valueLabel.setTypeface(valueLabel.typeface, Typeface.BOLD)
        valueLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 25f)
        labelsLL.addView(valueLabel)

        //create the statusLabel TextView and add it as a subview of labelsLL
        statusLabel = TextView(context)
        statusLabel.text = "VERY GOOD"
        statusLabel.setTextColor(statusColor)
        statusLabel.gravity = Gravity.CENTER
        statusLabel.setTypeface(statusLabel.typeface, Typeface.NORMAL)
        statusLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
        labelsLL.addView(statusLabel)

        //initialize a path, a paint and a RectF which are needed during the drawing phase
        path = Path()
        paint = Paint()
        radiusPathRectF = RectF()

        //center the mainBg ImageView
        val mainBgParams = mainBg.layoutParams as LayoutParams
        mainBgParams.addRule(CENTER_IN_PARENT, TRUE)
        mainBg.layoutParams = mainBgParams

        //center the needle RelativeLayout
        val needleParams = needle.layoutParams as LayoutParams
        needleParams.addRule(CENTER_IN_PARENT, TRUE)
        needle.layoutParams = needleParams

        //center the labels LinearLayout
        val labelsLLParams = labelsLL.layoutParams as LayoutParams
        labelsLLParams.addRule(CENTER_IN_PARENT, TRUE)
        labelsLL.layoutParams = labelsLLParams

        //set valueLabel margins
        val valueParams = valueLabel.layoutParams as LinearLayout.LayoutParams
        valueParams.setMargins(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, context.resources.displayMetrics).toInt())
        valueLabel.layoutParams = valueParams

        //set statusLabel margins
        val statusParams = statusLabel.layoutParams as LinearLayout.LayoutParams
        statusParams.setMargins(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, context.resources.displayMetrics).toInt())
        statusLabel.layoutParams = statusParams

        //set WillNotDraw to false to allow onDraw(Canvas canvas) to be called (This is needed when you have ViewGroups as subviews)
        setWillNotDraw(false)

        //set the value initially to 0
        setValue(0)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        super.onLayout(changed, l, t, r, b)
        val w = r - l
        val h = b - t

        //set the mainBg ImageView width and height
        val mainBgParams = mainBg.layoutParams as LayoutParams
        mainBgParams.width = (w / 1.2).toInt()
        mainBgParams.height = (w / 1.2).toInt()
        mainBg.layoutParams = mainBgParams

        //set the needle width
        val needleW = mainBgParams.height / 11

        //set the needle RelativeLayout width and height
        val needleParams = needle.layoutParams as LayoutParams
        needleParams.width = needleW
        needleParams.height = 2 * mainBgParams.height / 3
        needle.layoutParams = needleParams

        //set the polygon ImageView width and height to the same width of needle. Also add some top margin eg: 2 dps.
        val polygonParams = polygon.layoutParams as LayoutParams
        polygonParams.width = needleW
        polygonParams.height = needleW
        polygonParams.setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, context.resources.displayMetrics).toInt(), 0, 0)
        polygon.layoutParams = polygonParams
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        this.w = w
        this.h = h
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        // 1: Save the current drawing configuration
        canvas.save()

        // 2: Move to the center of our drawing rectangle and rotate so that we're pointing at the start of the first segment
        canvas.translate(w.toFloat() / 2, h.toFloat() / 2)
        canvas.rotate((rotationAngle - Math.PI / 2).toFloat())

        // 3: Set up the user's line width
        paint.setStrokeWidth(segmentWidth)

        // 4: Calculate the size of each segment in the total gauge in degrees
        val segmentAngle = totalAngle / segmentColors.size.toFloat()

        // 5: Calculate how wide the segment arcs should be
        val segmentRadius = (w - segmentWidth) / 2 - outerBezelWidth - innerBezelWidth

        // 6: Draw each segment
        for (index in segmentColors.indices) {
            val segment = segmentColors[index]

            // figure out where the segment starts in our arc in degrees
            val start = index.toFloat() * segmentAngle

            //activate its color
            paint.setColor(segment)

            // add a path for the segment
            radiusPathRectF.left = -segmentRadius/2
            radiusPathRectF.top = -segmentRadius/2
            radiusPathRectF.right = segmentRadius/2
            radiusPathRectF.bottom = segmentRadius/2
            path.addArc(radiusPathRectF, -90F, start + segmentAngle)

            // and stroke it using the activated color
            paint.setStyle(Paint.Style.STROKE)
            canvas.drawPath(path, paint)
        }

        // 7: Reset the graphics state
        canvas.restore()
    }

    /**
     * Call this helper method to set a new value
     * @param value must be a number between 0-100
     */
    fun setValue(value: Int) {

        // update the value label to show the exact number
        valueLabel.text = value.toString()

        // update the status label based on the value eg: VERY GOOD or GOOD
        statusLabel.text = if (value > 50) "VERY GOOD" else "GOOD"

        // figure out where the needle is, between 0 and 1 (This will set the min value to 0 and max value to 100)
        // in case you want to have a range between 0-1000 divide below with 1000
        val needlePosition = value.toFloat() / 100

        // create a lerp from the start angle (rotationAngle) through to the end angle (rotationAngle + totalAngle)
        val lerpFrom = rotationAngle
        val lerpTo = rotationAngle + totalAngle

        // lerp from the start to the end position, based on the needle's position
        val needleRotation = lerpFrom + (lerpTo - lerpFrom) * needlePosition

        //in android rotation is in degrees instead of radians
        needle.rotation = needleRotation
    }
}

其中R.drawable.credit_score_meter是您的主要背景图片:

background Image

R.drawable.ic_needle 是针矢量图标:

<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="23dp"
    android:height="23dp"
    android:viewportWidth="23"
    android:viewportHeight="23">
  <path
      android:pathData="M11.8054,0.8052L22.8054,22.8052C22.8054,22.8052 16.0848,21.4364 11.7257,21.4441C7.4285,21.4517 0.8054,22.8052 0.8054,22.8052L11.8054,0.8052Z"
      android:fillColor="#2BE252"/>
</vector>

2.在 xml 布局中定义自定义 GaugeView,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/white">

    <com.my.packagename.GaugeView
        android:id="@+id/gaugeView"
        android:layout_width="300dp"
        android:layout_height="300dp"
        android:layout_centerInParent="true"/>

</RelativeLayout>

3.最后使用上面的 GaugeView 以编程方式设置新值,如下所示:

val gaugeView = findViewById<GaugeView>(R.id.gaugeView)
gaugeView.setValue(75) //values range 0-100 

从上面的代码来看,与 swift 版本的主要区别是旋转,在 Android 中使用度数而不是 iOS 中使用的弧度。 另请注意,上面的示例具有 0 到 100 之间的数字范围。如果您想要 0 到 1000 之间的范围,您可以更改此行 val NeedlePosition = value.toFloat()/100 并除以为1000。当然您可以根据您的需要进一步修改代码。这是您起点的示例版本。希望对您有所帮助。

结果:

gauge_view_result

动画针

要将针动画到特定的角度值,您可以简单地使用 needle.animate().rotationBy(angle) 中的构建,如下所示:

needle.animate()
    .rotationBy(rotationAngle)
    .setDuration(500)
    .setInterpolator(LinearInterpolator())
    .start()

如果旋转角度为正值,则顺时针旋转,如果为负值,则逆时针旋转。 我已经用上面的动画实现了一个示例,您可以在其中使用并进一步修改它以适合您的情况。只需将 fun setValue(value: Int) 替换为新的,如下所示:

private var prevValue = -1;
/**
 * Call this helper method to set a new value
 * @param value must be a number between 0-100
 */
fun setValue(value: Int) {

    if(prevValue == value)
        return

    // update the value label to show the exact number
    valueLabel.text = value.toString()

    // update the status label based on the value eg: VERY GOOD or GOOD
    statusLabel.text = if (value > 50) "VERY GOOD" else "GOOD"

    // figure out where the needle is, between 0 and 1 (This will set the min value to 0 and max value to 100)
    // in case you want to have a range between 0-1000 divide below with 1000
    val needlePosition = value.toFloat() / 100

    // create a lerp from the start angle (rotationAngle) through to the end angle (rotationAngle + totalAngle)
    val lerpFrom = rotationAngle
    val lerpTo = rotationAngle + totalAngle

    // lerp from the start to the end position, based on the needle's position
    val needleRotation = lerpFrom + (lerpTo - lerpFrom) * needlePosition

    //calculate the rotationBy angle (rotation delta angle)
    var rot = 0f
    val diff = Math.abs(Math.abs(needle.rotation) - Math.abs(needleRotation))
    if(needle.rotation == 0f && needleRotation == rotationAngle)
    {
        rot = rotationAngle
    }
    else if(needle.rotation == rotationAngle && needleRotation == 135f){
        rot = 135f*2;
    }
    else if(needleRotation < 0)
    {
        if(needleRotation < needle.rotation){
            if(needle.rotation > 0) {
                if (needle.rotation == 135f){
                    rot = -(135f*2 - diff)
                }
                else if(needleRotation == rotationAngle){
                    rot = -(135f + Math.abs(needle.rotation))
                }
                else {
                   rot = -(Math.abs(needle.rotation) + Math.abs(needleRotation))
                }
            }
            else {
                rot = -diff
            }
        }
        else if(needleRotation > needle.rotation){
            rot = +diff
        }
        else{
            rot = rotationAngle
        }
    }
    else if(needleRotation > 0)
    {
        if(needleRotation < needle.rotation){
            rot = -diff
        }
        else if(needleRotation > needle.rotation){
            if(needle.rotation < 0) {
                if (needle.rotation == rotationAngle){
                    rot = 135f + Math.abs(needleRotation)
                }
                else{
                    rot = Math.abs(needle.rotation) + Math.abs(needleRotation)
                }
            }
            else {
                rot = +diff
            }
        }
        else{
            rot = rotationAngle
        }
    }
    else if (needleRotation == 0f)
    {
        if(needle.rotation == 135f)
            rot = -diff
        else
            rot = +diff
    }

    //and animate the needle using the rotationBy()
    needle.animate()
        .rotationBy(rot) //if this value is negative it goes anticlockwise and if its positive is goes clockwise
        .setDuration(500)
        .setInterpolator(LinearInterpolator())
        .start()

    prevValue = value
}

您可以像下面的示例一样测试它:

var i = 1
object : CountDownTimer(202000, 2000) {
    override fun onTick(millisUntilFinished: Long) {
        changeValue(i++)
    }
    override fun onFinish() {}
}.start()

其中 fun changeValue(number: Int) 是以下助手:

fun changeValue(number: Int){
    gaugeView.setValue(0)
    Thread(Runnable {
        Handler(Looper.getMainLooper()).postDelayed(Runnable {
            gaugeView.setValue(number)
        }, 1000)
    }).start()
}

动画结果:

animated_gauge_view

关于android - 创建从 iOS (Swift) 到 Android(Kotlin) 的 Needle GaugeView,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/74010297/

相关文章:

android - TabHost 颜色在 Android 版本之间发生了变化

java - 如何在android中选择网站数据库中的数据

android - Android:带翻新功能的库项目导致NoClassDefFoundError

java - Firebase 用户通过 Google 注册

ios - 通过 MKAnnotation Callout Swift 创建文本字段

ios - 通过 UIActivityViewController 的 Facebook 共享不会在共享对话框中显示所有事件项目

ios - 设备旋转后的 UICollectionView contentOffset

cocoa-touch - 在 swift xcode 中初始化

ios - 使用 Segue 将数据传递给不同的 View Controller

ios - 错误 : 'OneSignal/OneSignal.h' file not found #import <OneSignal/OneSignal. h>