skip to Main Content

Need help in translating a Gauge View in iOS written in Swift to Android written in Kotlin in Custom 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()
    }
}

What I’m trying to do is
Use a FrameLayout on which draw segments which are the small grey lines. The next thing I will try to add is the image view, & the needle view, but I’m not sure how to appropriately rotate it.

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

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

3

Answers


  1. I wouldn’t use that approach at all. Don’t use a Framelayout and try to use embedded ImageViews. Just use a custom View with View as a parent, and draw the background yourself with Canvas.drawBitmap. As for rotating the needle- apply a rotation matrix when you draw the needle. To create a rotation matrix, just do

    val matrix = Matrix()
    martix.setRotate(degrees, pivotX, pivotY)
    canvas.drawBitmap(needleBitmap, matrix, paint)
    

    That will draw the needle image rotated by degree degrees. The pivot point will be at the location you specified (should basically be the base of the needle)

    For your view, you can either use a background precalculated with all the gray segments (a vector drawable would be really good for this) or you can draw them yourself- basically 1 drawRect command per segment.

    Login or Signup to reply.
  2. I suggest you to try this lib for Android/Kotlin

    Dynamic Speedometer, Gauge for Android

    It can be easily customized for looking similar to your iOS implementation

    Login or Signup to reply.
  3. To achieve the above Needle GaugeView with a similar behaviour in android you will need a custom ViewGroup to be able to add subviews for the background image and the needle like the RelativeLayout or the FrameLayout as you suggested on your question. Based on your swift code i have implemented a similar GaugeView in kotlin which extends from a RelativeLayout.

    1.First create your custom GaugeView which extends from a RelativeLayout like below:

    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
        }
    }
    

    where R.drawable.credit_score_meter is your main background Image:

    background Image

    and the R.drawable.ic_needle is the needle vector icon:

    <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.Define the custom GaugeView in your xml layout like:

    <?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.And finally use the above GaugeView to programmatically set the new value like below:

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

    From the above code the main difference with the swift version is the rotation where in android uses degrees instead of radians used in iOS.
    Note also that the above example has a number of range between 0 to 100. In case you want a range from 0 to 1000 you can change this line val needlePosition = value.toFloat() / 100 and divide with 1000. Of course you can modify further the code based on your needs. This is a sample version for your starting point. Hope it helps.

    Result:

    gauge_view_result

    Animated needle

    To animate the needle to a specific degree value you can simply use the build in needle.animate().rotationBy(angle) like this:

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

    If the rotationAngle is a positive value it goes clockwise and if is a negative value it goes anticlockwise.
    I have implemented an example with the above animation where you can play with and modify it further to suit your case. Just replace the fun setValue(value: Int) with the new one like below:

    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
    }
    

    And you can test it like the below sample:

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

    where fun changeValue(number: Int) is the below helper:

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

    Animated Result:

    animated_gauge_view

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search