When creating a custom video player, you need to have a component halfway between a UISlider, allowing your to interactively track and seek a particular position in the video, but also show progress continuously while also eventually showing buffering.
Even though there isn’t any component like this directly available in UIKit, it appears it’s fairly easy to make something like this, interactive and fully customizable:
Creating a custom view to display progress and buffer
Let’s start with the basics, we need a subclass of UIView for which we override all possible initializers:
final class CustomProgressBar: UIView {
// MARK: - Initializers
init() {
fatalError("Unsupported initializer init(), use init(frame:) instead")
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
// TODO
}
}
Now let’s say we want to add live preview in interface builder, we have to make it @IBDesignable
and override the prepareForInterfaceBuilder
:
@IBDesignable final class CustomProgressBar: UIView {
// MARK: - Initializers
init() {
fatalError("Unsupported initializer init(), use init(frame:) instead")
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
// TODO
}
// MARK: - Interface builder
#if INTERFACE_BUILDER
override func prepareForInterfaceBuilder() {
super.prepareForInterfaceBuilder()
commonInit()
}
#endif
}
At this stage, nothing appears in interface builder yet, this is normal, we didn’t add anything to our view yet.
The progress view will be composed of several layers: the track in the background, the buffer one level up, the actual progress one level up and finally the knob or thumb.
Let’s create and add our subviews:
private lazy var trackView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.masksToBounds = true
return view
}()
private lazy var progressView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
private lazy var bufferView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
private lazy var thumbView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.masksToBounds = true
return view
}()
private var trackHeightConstraint: NSLayoutConstraint!
private var thumbHeightConstraint: NSLayoutConstraint!
private var progressViewWidthConstraint: NSLayoutConstraint!
private var bufferViewWidthConstraint: NSLayoutConstraint!
Then in the commonInit
method:
private func commonInit() {
// View must be the same height as intrinsicContentSize
addConstraint(NSLayoutConstraint(item: self,
attribute: .height,
relatedBy: .equal,
toItem: nil,
attribute: .notAnAttribute,
multiplier: 1.0,
constant: thumbRadius))
// Configure and add track view
trackView.backgroundColor = trackColor
trackView.layer.cornerRadius = trackRadius
addSubview(trackView)
addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[trackView]|",
options: .init(),
metrics: nil,
views: ["trackView": trackView]))
addConstraint(NSLayoutConstraint(item: trackView,
attribute: .centerY,
relatedBy: .equal,
toItem: self,
attribute: .centerY,
multiplier: 1.0,
constant: 0))
trackHeightConstraint = NSLayoutConstraint(item: trackView,
attribute: .height,
relatedBy: .equal,
toItem: nil,
attribute: .notAnAttribute,
multiplier: 1.0,
constant: trackHeight)
addConstraint(trackHeightConstraint)
// Configure and add buffer view
bufferView.backgroundColor = bufferColor
trackView.addSubview(bufferView)
bufferViewWidthConstraint = NSLayoutConstraint(item: bufferView,
attribute: .width,
relatedBy: .equal,
toItem: nil,
attribute: .notAnAttribute,
multiplier: 1.0,
constant: 0.0)
addConstraint(bufferViewWidthConstraint)
addConstraint(NSLayoutConstraint(item: bufferView,
attribute: .height,
relatedBy: .equal,
toItem: trackView,
attribute: .height,
multiplier: 1.0,
constant: 0))
// Configure and add progress view
progressView.backgroundColor = progressColor
trackView.addSubview(progressView)
progressViewWidthConstraint = NSLayoutConstraint(item: progressView,
attribute: .width,
relatedBy: .equal,
toItem: nil,
attribute: .notAnAttribute,
multiplier: 1.0,
constant: 0.0)
addConstraint(progressViewWidthConstraint)
addConstraint(NSLayoutConstraint(item: progressView,
attribute: .height,
relatedBy: .equal,
toItem: trackView,
attribute: .height,
multiplier: 1.0,
constant: 0))
// Configure and add thumb view
addSubview(thumbView)
thumbView.alpha = isThumbVisible ? 1.0 : 0.0
thumbView.backgroundColor = thumbColor
thumbView.layer.cornerRadius = thumbRadius / 2.0
addConstraint(NSLayoutConstraint(item: thumbView,
attribute: .centerY,
relatedBy: .equal,
toItem: self,
attribute: .centerY,
multiplier: 1.0,
constant: 0.0))
addConstraint(NSLayoutConstraint(item: thumbView,
attribute: .width,
relatedBy: .equal,
toItem: thumbView,
attribute: .height,
multiplier: 1.0,
constant: 0.0))
thumbHeightConstraint = NSLayoutConstraint(item: thumbView,
attribute: .height,
relatedBy: .equal,
toItem: nil,
attribute: .notAnAttribute,
multiplier: 1.0,
constant: thumbRadius)
addConstraint(thumbHeightConstraint)
addConstraint(NSLayoutConstraint(item: thumbView,
attribute: .centerX,
relatedBy: .equal,
toItem: progressView,
attribute: .trailing,
multiplier: 1.0,
constant: 0.0))
}
The layout using auto layout, and we observe these all depend on some customizable properties, that we will mark as @IBInspectable
so we can modify them directly from Interface Builder inspector:
@IBInspectable var isThumbVisible: Bool = true {
didSet {
thumbView.alpha = isThumbVisible ? 1.0 : 0.0
}
}
@IBInspectable var progress: Float = 0.0 {
didSet {
refreshProgress()
}
}
@IBInspectable var buffer: Float = 0.0 {
didSet {
refreshBuffer()
}
}
@IBInspectable var trackColor: UIColor = UIColor.white {
didSet {
trackView.backgroundColor = trackColor
}
}
@IBInspectable var trackHeight: CGFloat = 10.0 {
didSet {
trackHeightConstraint.constant = trackHeight
}
}
@IBInspectable var trackRadius: CGFloat = 5.0 {
didSet {
trackView.layer.cornerRadius = trackRadius
}
}
@IBInspectable var bufferColor: UIColor = UIColor.black.withAlphaComponent(0.7) {
didSet {
bufferView.backgroundColor = bufferColor
}
}
@IBInspectable var progressColor: UIColor = UIColor.black {
didSet {
progressView.backgroundColor = progressColor
}
}
@IBInspectable var thumbColor: UIColor = UIColor.black {
didSet {
thumbView.backgroundColor = thumbColor
}
}
@IBInspectable var thumbRadius: CGFloat = 20.0 {
didSet {
thumbHeightConstraint.constant = thumbRadius
thumbView.layer.cornerRadius = thumbRadius / 2.0
}
}
All these @IBInspectable
properties have default values that can be overridden in code or directly in storyboard or .xib files.
Every-time the progress or buffer changes, we have to update the constraints of some of subviews:
private func refreshProgress() {
progressViewWidthConstraint.constant = bounds.width * CGFloat(min(max(progress, 0.0), 1.0))
}
private func refreshBuffer() {
bufferViewWidthConstraint.constant = bounds.width * CGFloat(min(max(buffer, 0.0), 1.0))
}
Finally, it’s important to override both layoutSubviews
to make sure our subviews are property placed when screen size changes (final size, rotations, etc) and the intrinsicContentSize
, especially because we want the height to automatically be decided based on our track height and thumb height instead of adding a constraint ourselves:
override func layoutSubviews() {
super.layoutSubviews()
refreshProgress()
refreshBuffer()
}
override var intrinsicContentSize: CGSize {
let desiredHeight = max(trackHeight, thumbRadius)
return CGSize(width: UIView.noIntrinsicMetric, height: desiredHeight)
}
Adding interactivity
For it to be interactive, we first need to transform our view superclass from a UIView to a UIControl:
@IBDesignable final class CustomProgressBar: UIControl {
Then, we override the touchesBegan
, touchesMoved
, touchesCancelled
and touchesEnded
methods to send basic actions:
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
sendActions(for: .touchDown)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesMoved(touches, with: event)
guard let touch = touches.first else {
return
}
let position = touch.location(in: self)
progress = min(max(Float(position.x / bounds.width), 0.0), 1.0)
sendActions(for: .valueChanged)
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesCancelled(touches, with: event)
sendActions(for: .touchUpInside) // Consider this as an ended event instead
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
sendActions(for: .touchUpInside)
}
One final touch is to make the touch zone bigger since the knob area can be fairly small:
override func point(inside point: CGPoint, with _: UIEvent?) -> Bool {
let expandedBounds = bounds.insetBy(dx: min(bounds.width - 44.0, 0), dy: min(bounds.height - 44.0, 0))
expandedBounds.contains(point)
return bounds.contains(point)
}
Integration in Interface Builder
To use our custom progress bar, we first drag a new UIView then change its class in the inspector:
We now have new options for customization thanks to the @IBInspectable:
Final code
Final code can be found here: https://gist.github.com/cyrilchandelier/602afdbf23ab02e2e9a77bde7bb2a105