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