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)
}
}
3
Answers
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
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.
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
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 theRelativeLayout
or theFrameLayout
as you suggested on your question. Based on your swift code i have implemented a similar GaugeView in kotlin which extends from aRelativeLayout
.1.First create your custom GaugeView which extends from a
RelativeLayout
like below:where
R.drawable.credit_score_meter
is your main background Image:and the
R.drawable.ic_needle
is the needle vector icon:2.Define the custom GaugeView in your xml layout like:
3.And finally use the above GaugeView to programmatically set the new value like below:
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: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:And you can test it like the below sample:
where
fun changeValue(number: Int)
is the below helper:Animated Result: