How I’ve implemented custom slider. Telegram-Drawing-Text-Editing, ep. 4
Hello, the goal is to create reusable slider for the app I’ve been working on.
This is what you can see on resource video in at least two places in the app. The first one which on the image above, is underneath chosen pen. And the second one on the left side of the screen that appear during text editing.
To be honest I thought that they would provide us with ready solution like for buttons or diffrerent types of pens. But no. We gotta build it ourselves.
We’ll start from subclassing UISlider
view with default implementation:
class IPSliderView: UISlider {
// 1
private let thumbView: UIView = {
let view = UIView()
view.backgroundColor = .white
view.layer.borderColor = UIColor.white.cgColor
view.layer.backgroundColor = UIColor.white.cgColor
return view
}()
private let baseLayer = CAShapeLayer()
var sFrame: CGRect
// 2
override func draw(_ rect: CGRect) {
super.draw(rect)
setup()
}
override init(frame: CGRect) {
sFrame = frame
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) han't been implemented")
}
private func setup() {
clear()
// 3
let thumb = thumbImage(radius: sFrame.height)
setThumbImage(thumb, for: .normal)
setThumbImage(thumb, for: .highlighted)
}
private func clear() {
tintColor = .clear
maximumTrackTintColor = .clear
backgroundColor = .clear
thumbTintColor = .clear
}
private func thumbImage(radius: CGFloat) -> UIImage {
thumbView.frame = CGRect(x: 0, y: radius / 2,
width: radius, height: radius)
thumbView.layer.cornerRadius = radius / 2
// Extension to a UIView can be found down bellow
return thumbView.snapshot
}
}
- Let’s implement a UIView that will represent our thumb, the part of a slider which you drag.
- Override draw(_:) method to render our future drawings on UISlider.
- In the custom setup() method we’ve put clear() and thumbImage(radius:) methods to make sure that no collors are duplicated and to build our circle and return a UIImage ready to use, respectively.
Then we setup thumb images to different states. .normal
and .highlighted
more than enough for our current goal.
UIView extension that I found on SO which works like a charm. Convert UIView to an UIImage:
extension UIView {
var snapshot: UIImage {
let renderer = UIGraphicsImageRenderer(bounds: bounds)
let capturedImage = renderer.image { context in
layer.render(in: context.cgContext)
}
return capturedImage
}
}
Since the thumb is ready now only background remains to be added. let’s enrich our setup()
method with the call of
private func setup() {
clear()
// Add this call
buildBaseLayer()
let thumb = thumbImage(radius: sFrame.height)
setThumbImage(thumb, for: .normal)
setThumbImage(thumb, for: .highlighted)
}
And here goes buildBaseLayer()
himself
private func buildBaseLayer() {
baseLayer.fillColor = UIColor.lightGray.cgColor
baseLayer.path = createBackground(rect: sFrame).cgPath
// Here
layer.insertSublayer(baseLayer, at: 0)
}
I would like to point your attention to this line. It is important to use insertSublayer at 0 index because if you use usual addSublayer your base aka background layer will be above everything which is not what we’re looking for, so just keep in mind.
Let’s have a look once again on our desired design for slider.
Ok, this doesn’t really help, let me show you a trick which reduce difficulty of this problem to the zero.
Much better. The red area is our frame of this custom slider. There’re five points, you see only four because on the left side put two of them would look bulky. So five.
With this model in mind and sent rect CGRect property we’re gonna start building our slider’s background. And for those who’s dealing with Axises on iOS the 0,0 point is located on the top left corner.
I decided to make reused properties, because as usual you’ll deal with something like rect.bound.maxY / 2
and other magic numbers, so to avoid that we just make our life easier.
// This is located inside createBackground(rect: CGRect) method
let path = UIBezierPath()
let minX = rect.minX
let minY = rect.minY
let maxX = rect.maxX
let maxY = rect.maxY
let centerY = maxY / 2
let leftArcRadius = maxX * 0.01
let leftArcPivot = leftArcRadius
let rightArcRadius = maxY / 2
I made these parameters adjustable, so you can experiment with them.
Here only two important things. The first one left arc radius/pivot. I separated these the same values because they play different roles in building. And the second one is rightArcRadius
. Which is a distance from the top/bottom point to the right corner. This value let us keep slider inside the frame.
The first step in building is to call move()
to a specified point in our case this is (x: minX, y: centerY)
:
path.move(to: CGPoint(x: minX, y: centerY))
Then we need to make topLeftArc
: We move center point to one “percent of maxX” or leftArcPivot
on X-axis and remain on center on Y-axis.
path.addArc(withCenter: CGPoint(x: leftArcPivot, y: centerY),
radius: leftArcRadius,
startAngle: Double.pi,
endAngle: Double.pi * 3 / 2,
clockwise: true)
Adding a line to the top point, the point that intersects top border of the frame. This will be a start point of right arc.
path.addLine(to: CGPoint(x: maxX - rightArcRadius, y: minY))
Well, here’s a right arc:
path.addArc(withCenter: CGPoint(x: maxX - rightArcRadius, y: centerY),
radius: rightArcRadius,
startAngle: Double.pi * 3 / 2,
endAngle: Double.pi / 2,
clockwise: true)
This arc is completed by one call addArc(withCenter:)
because here we know exactly where to begin from. (Maybe in the future I’ll refactor code to use one arc for the left side of slider. If that happens, I’ll make an update for this article).
The next one is a bit tricky. Meanwhile we know that we need to use 1 percent of maxX to get right X-axis location. But what point to use for Y? To match all points and build proper bottom left arc we need to move on radius/leftArcPivot from the y center.
path.addLine(to: CGPoint(x: leftArcPivot, y: centerY + leftArcPivot))
And finally the bottom arc. Actually it is pretty the same as our first arc, but only with difference in start and end angles
path.addArc(withCenter: CGPoint(x: leftArcPivot, y: centerY),
radius: leftArcRadius,
startAngle: Double.pi / 4,
endAngle: Double.pi,
clockwise: true)
path.close()
path.fill()
As I mentioned in the text this implementation is adjustable, so it is up to you what aspect ratio will slider have. This is the method we’ve just written:
func createBackground(rect: CGRect) -> UIBezierPath {
let path = UIBezierPath()
let minX = rect.minX
let minY = rect.minY
let maxX = rect.maxX
let maxY = rect.maxY
let centerY = maxY / 2
let leftArcRadius = maxX * 0.01
let leftArcPivot = leftArcRadius
let rightArcRadius = maxY / 2
path.move(to: CGPoint(x: minX, y: centerY))
// top left arc
path.addArc(withCenter: CGPoint(x: leftArcPivot, y: centerY),
radius: leftArcRadius,
startAngle: Double.pi,
endAngle: Double.pi * 3 / 2,
clockwise: true)
// top line
path.addLine(to: CGPoint(x: maxX - rightArcRadius, y: minY))
// rigth arc
path.addArc(withCenter: CGPoint(x: maxX - rightArcRadius, y: centerY),
radius: rightArcRadius,
startAngle: Double.pi * 3 / 2,
endAngle: Double.pi / 2,
clockwise: true)
// bottom line
path.addLine(to: CGPoint(x: leftArcPivot, y: centerY + leftArcPivot))
// bottom left arc
path.addArc(withCenter: CGPoint(x: leftArcPivot, y: centerY),
radius: leftArcRadius,
startAngle: Double.pi / 4,
endAngle: Double.pi,
clockwise: true)
path.close()
path.fill()
return path
}
You can examine full project and the slider implementation especially on my repository by the link → Telegram-Like-Drawing-Text-Editing