Skip to content

Instantly share code, notes, and snippets.

@trilliwon
Created July 20, 2019 10:26
Show Gist options
  • Save trilliwon/7571706382dc1813b9945e6f5a541fc4 to your computer and use it in GitHub Desktop.
Save trilliwon/7571706382dc1813b9945e6f5a541fc4 to your computer and use it in GitHub Desktop.
Play pause button
//
// PlayButton.swift
// ProgressView
//
// Created by WonJo on 10/03/2019.
// Copyright © 2019 WonJo. All rights reserved.
//
import UIKit
public enum PlayAction {
case pause
case play
/// `.Pause` when self is `.Play`, .Play when `self` is `.Pause`
public var reverseAction: PlayAction {
return self == .play ? .pause : .play
}
}
public class PlayButton: UIButton {
public var reset: (() -> Void)?
private var progress: Float = 0.0 {
didSet {
animate(from: CGFloat(oldValue), to: CGFloat(progress), duration: 0.0)
}
}
private var lineWidth: CGFloat {
return pauseLineWidth * 0.5
}
private var timer: Timer?
private lazy var ringLayer: CAShapeLayer = {
let ringLayer = CAShapeLayer()
ringLayer.lineWidth = lineWidth
ringLayer.frame = bounds
return ringLayer
}()
private let angle = (start: -CGFloat.pi / 2, end: 3 * CGFloat.pi / 2)
private let impactGenerator = UIImpactFeedbackGenerator(style: .medium)
func animate(from: CGFloat, to: CGFloat, duration: CFTimeInterval) {
let animation = CABasicAnimation()
animation.keyPath = "strokeEnd"
animation.fromValue = from
animation.toValue = to
animation.duration = duration
animation.isRemovedOnCompletion = false
animation.isAdditive = true
animation.fillMode = CAMediaTimingFillMode.forwards
ringLayer.add(animation, forKey: "strokeEnd")
}
private func drawRingLayer() {
ringLayer.removeFromSuperlayer()
ringLayer.path = UIBezierPath(arcCenter: CGPoint(x: bounds.width / 2, y: bounds.height / 2),
radius: bounds.height,
startAngle: angle.start,
endAngle: angle.end,
clockwise: true).cgPath
ringLayer.backgroundColor = UIColor.clear.cgColor
ringLayer.fillColor = nil
ringLayer.strokeStart = 0.0
ringLayer.strokeEnd = 0.0
ringLayer.strokeColor = tintColor.cgColor
layer.addSublayer(ringLayer)
}
override public func draw(_ rect: CGRect) {
drawRingLayer()
}
public private(set) var buttonAction = PlayAction.play
/// The ratio between the width of the pause line and the width of the button. Defaults to `0.27`.
public var pauseLineWidthRatio: CGFloat = 0.3
/// Returns how wide the pause line is for the current bounds
public var pauseLineWidth: CGFloat {
return bounds.width * pauseLineWidthRatio
}
/// This property determines how much the pause shape will be scaled in relation to the bounds. Defaults to `0.5`.
public var pauseScale: CGFloat = 0.9
/// Returns how wide the pause line is during the scaling animation.
public var scaledPauseLineWidth: CGFloat {
return pauseScale * pauseLineWidth
}
/// Determines how long the animation will take.
public var animationDuration: CFTimeInterval = 0.3
public override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
/// Initializes and returns a newly allocated `PlayButton` object with the specified parameters.
///
/// - parameter origin: The origin of the button
/// - parameter width: The width of the button, which will also be used as its height. Defaults to `30.0`
/// - parameter initialAction: The initial button action, that the button represents. Defaults to `.Play`
///
/// - returns: An initialized `PlayButton` object
public convenience init(initialAction: PlayAction = .play) {
self.init(frame: CGRect.zero)
self.buttonAction = initialAction
setup()
}
public required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
public override var tintColor: UIColor! {
didSet {
leftShapeLayer.fillColor = tintColor.cgColor
rightShapeLayer.fillColor = tintColor.cgColor
}
}
private enum Animation: String {
case leftPlayToPause, rightPlayToPause, leftPauseToPlay, rightPauseToPlay
var key: String {
return self.rawValue
}
var keyTimes: [Float] {
switch self {
case .leftPauseToPlay:
return [0.0, 0.143, 0.486, 0.486, 0.714, 0.714, 0.811, 0.908, 1.0]
case .rightPauseToPlay:
return [0.0, 0.143, 0.429, 0.543, 0.629, 0.657, 0.714, 0.714, 1.0]
case .leftPlayToPause:
return [0.0, 0.097, 0.194, 0.286, 0.857, 1.0]
case .rightPlayToPause:
return [0.0, 0.286, 0.286, 0.571, 0.686, 0.771, 0.8, 0.857, 1.0]
}
}
var timingFunctions: [CAMediaTimingFunction]? {
switch self {
case .leftPauseToPlay:
return [
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.default), // ignored, because interpolated paths are the same
CAMediaTimingFunction(name: CAMediaTimingFunctionName.default), // ignored, because the keyTimes between the frames are the same -> instant change
CAMediaTimingFunction(name: CAMediaTimingFunctionName.default), // ignored, because interpolated paths are the same (zeroPath)
CAMediaTimingFunction(name: CAMediaTimingFunctionName.default), // ignored, because the keyTimes between the frames are the same -> instant change
CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)]
case .rightPauseToPlay:
return [
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.default),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.default)]
case .leftPlayToPause:
return [
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)]
case .rightPlayToPause:
return [ // default
CAMediaTimingFunction(name: CAMediaTimingFunctionName.default), // ignored, because interpolated paths are the same (zeroPath)
CAMediaTimingFunction(name: CAMediaTimingFunctionName.default), // ignored, because the keyTimes between the frames are the same -> instant change
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut),
CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)]
}
}
func finalFrame(lineWidth: CGFloat, atScale scale: CGFloat, bounds: CGRect) -> CGPath {
switch self {
case .leftPauseToPlay:
return playPathAtScale(scale: 1.0, lineWidth: lineWidth, bounds: bounds)
case .rightPauseToPlay:
return zeroPath()
case .leftPlayToPause:
return leftMorph2PlayPathAtScale(scale: 1.0, lineWidth: lineWidth, bounds: bounds)
case .rightPlayToPause:
return rightPausePathAtScale(scale: 1.0, lineWidth: lineWidth, bounds: bounds, xOffset: bounds.width - lineWidth, bending: 0.0)
}
}
func keyframes(forLineWidth lineWidth: CGFloat, atScale scale: CGFloat, bounds: CGRect) -> [CGPath] {
let scaledLineWidth = lineWidth * scale
switch self {
case .leftPauseToPlay:
return [
leftMorph2PlayPathAtScale(scale: 1.0, lineWidth: lineWidth, bounds: bounds),
leftMorph2PlayPathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds),
leftMorph2PlayPathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds),
zeroPath(), // hide when right shape layer "rebounces" into left one
zeroPath(), // stay hidden until right layer finished bouncing
leftMorph2PlayPathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds), // appear again
leftMorph1PlayPathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds),
playPathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds),
playPathAtScale(scale: 1.0, lineWidth: lineWidth, bounds: bounds)
]
case .rightPauseToPlay:
return [
rightPausePathAtScale(scale: 1.0, lineWidth: lineWidth, bounds: bounds, xOffset: bounds.width - lineWidth, bending: 0.0),
rightPausePathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds, xOffset: scale * bounds.width - scaledLineWidth, bending: 0.0),
rightPausePathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds, xOffset: 0.0, bending: 1.0),
rightPausePathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds, xOffset: 0.0, bending: -0.7),
rightPausePathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds, xOffset: 0.0, bending: 0.3),
rightPausePathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds, xOffset: 0.0, bending: -0.15),
rightPausePathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds, xOffset: 0.0, bending: 0.0),
zeroPath()]
case .leftPlayToPause:
return [
playPathAtScale(scale: 1.0, lineWidth: lineWidth, bounds: bounds),
playPathAtScale(scale: scale, lineWidth: lineWidth, bounds: bounds),
leftMorph1PlayPathAtScale(scale: scale, lineWidth: lineWidth, bounds: bounds),
leftMorph2PlayPathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds),
leftMorph2PlayPathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds),
leftMorph2PlayPathAtScale(scale: 1.0, lineWidth: lineWidth, bounds: bounds)]
case .rightPlayToPause:
return [
zeroPath(),
zeroPath(),
rightPausePathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds, xOffset: 0.0, bending: 0.0),
rightPausePathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds, xOffset: scale * bounds.width - scaledLineWidth, bending: -1.0),
rightPausePathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds, xOffset: scale * bounds.width - scaledLineWidth, bending: 0.7),
rightPausePathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds, xOffset: scale * bounds.width - scaledLineWidth, bending: -0.3),
rightPausePathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds, xOffset: scale * bounds.width - scaledLineWidth, bending: 0.15),
rightPausePathAtScale(scale: scale, lineWidth: scaledLineWidth, bounds: bounds, xOffset: scale * bounds.width - scaledLineWidth, bending: 0.0),
rightPausePathAtScale(scale: 1.0, lineWidth: lineWidth, bounds: bounds, xOffset: bounds.width - lineWidth, bending: 0.0)]
}
}
/// Returns a keyframe animation with given parameters
///
/// - parameter duration: how long the animation should take in total
/// - parameter lineWidth: how thick the paused line is
/// - parameter scale: how small the button is scaled during animation
/// - parameter bounds: the bounds of the button
///
/// - returns: Returns a keyframe animation with given parameters
func keyframeAnimation(withDuration duration: CFTimeInterval, lineWidth: CGFloat, scale: CGFloat, bounds: CGRect) -> CAKeyframeAnimation {
let animation = CAKeyframeAnimation(keyPath: "path")
animation.duration = duration
animation.values = keyframes(forLineWidth: lineWidth, atScale: scale, bounds: bounds)
animation.keyTimes = self.keyTimes as [NSNumber]
animation.timingFunctions = self.timingFunctions
return animation
}
private func zeroPath() -> CGPath {
let margin = 0.0
let playPath = UIBezierPath()
playPath.move(to: CGPoint(x: margin, y: margin))
playPath.close()
return playPath.cgPath
}
private func playPathAtScale(scale: CGFloat, lineWidth: CGFloat, bounds: CGRect) -> CGPath {
let margin = (1.0 - scale) / 2.0 * bounds.width
let playPath = UIBezierPath()
playPath.move(to: CGPoint(x: margin, y: margin))
playPath.addLine(to: CGPoint(x: margin + lineWidth, y: margin + floor(lineWidth / sqrt(3))))
playPath.addLine(to: CGPoint(x: bounds.maxX - margin, y: bounds.midY))
playPath.addLine(to: CGPoint(x: bounds.maxX - margin, y: bounds.midY))
playPath.addLine(to: CGPoint(x: margin + lineWidth, y: bounds.maxY - margin - floor(lineWidth / sqrt(3))))
playPath.addLine(to: CGPoint(x: margin, y: bounds.maxY - margin))
playPath.close()
return playPath.cgPath
}
private func leftMorph1PlayPathAtScale(scale: CGFloat, lineWidth: CGFloat, bounds: CGRect) -> CGPath {
let margin = (1.0 - scale) / 2.0 * bounds.width
let playPath = UIBezierPath()
playPath.move(to: CGPoint(x: margin, y: margin))
playPath.addLine(to: CGPoint(x: margin + lineWidth, y: margin + lineWidth / sqrt(3)))
playPath.addLine(to: CGPoint(x: margin + lineWidth, y: margin + lineWidth / sqrt(3)))
playPath.addLine(to: CGPoint(x: margin + lineWidth, y: bounds.maxY - margin - lineWidth / sqrt(3)))
playPath.addLine(to: CGPoint(x: margin + lineWidth, y: bounds.maxY - margin - lineWidth / sqrt(3)))
playPath.addLine(to: CGPoint(x: margin, y: bounds.maxY - margin))
playPath.close()
return playPath.cgPath
}
private func leftMorph2PlayPathAtScale(scale: CGFloat, lineWidth: CGFloat, bounds: CGRect) -> CGPath {
let margin = (1.0 - scale) / 2.0 * bounds.width
let playPath = UIBezierPath()
playPath.move(to: CGPoint(x: margin, y: margin))
playPath.addLine(to: CGPoint(x: margin + lineWidth, y: margin))
playPath.addLine(to: CGPoint(x: margin + lineWidth, y: margin + lineWidth / sqrt(3)))
playPath.addLine(to: CGPoint(x: margin + lineWidth, y: bounds.maxY - margin - lineWidth / sqrt(3)))
playPath.addLine(to: CGPoint(x: margin + lineWidth, y: bounds.maxY - margin))
playPath.addLine(to: CGPoint(x: margin, y: bounds.maxY - margin))
playPath.close()
return playPath.cgPath
}
private func rightPausePathAtScale(scale: CGFloat = 1.0, lineWidth: CGFloat, bounds: CGRect, xOffset: CGFloat = 0.0, bending: CGFloat = 0.0) -> CGPath {
let margin = (1.0 - scale) / 2.0 * bounds.width
let playPath = UIBezierPath()
playPath.move(to: CGPoint(x: margin + lineWidth + xOffset, y: margin))
playPath.addQuadCurve(to: CGPoint(x: margin + lineWidth + xOffset, y: bounds.maxY - margin),
controlPoint: CGPoint(x: margin + lineWidth + xOffset - bending * lineWidth, y: bounds.midY))
playPath.addLine(to: CGPoint(x: margin + xOffset, y: bounds.maxY - margin))
playPath.addQuadCurve(to: CGPoint(x: margin + xOffset, y: margin),
controlPoint: CGPoint(x: margin + xOffset - bending * lineWidth, y: bounds.midY))
playPath.close()
return playPath.cgPath
}
}
private let leftShapeLayer: CAShapeLayer = {
$0.contentsScale = UIScreen.main.scale
$0.fillColor = UIColor.black.cgColor
return $0
}(CAShapeLayer())
private let rightShapeLayer: CAShapeLayer = {
$0.contentsScale = UIScreen.main.scale
$0.fillColor = UIColor.black.cgColor
return $0
}(CAShapeLayer())
/// Set the button action for the button. If the `action` is the same as `buttonAction`, nothing happens. If `animated` is `true` the animation will take `animationDuration` seconds, when there is no animation currently going on. If `animated` is `false` or there is already an ongoing animation, the state will be immidiately set
///
/// - parameter action: The new button action
/// - parameter animated: Determines whether the state change should be animated (with duration `animationDuration`)
public func setButtonAction(action: PlayAction, animated: Bool) {
guard buttonAction != action else { return }
buttonAction = action
switch buttonAction {
case .pause:
if !animated || leftShapeLayer.animation(forKey: Animation.leftPauseToPlay.key) != nil { // ongoing animation is cancelled
setModelToFinalPath()
} else {
setModelToFinalPath()
// left layer
leftShapeLayer.add(Animation.leftPlayToPause.keyframeAnimation(withDuration: animationDuration,
lineWidth: pauseLineWidth,
scale: pauseScale,
bounds: bounds), forKey: Animation.leftPlayToPause.key)
// right layer
rightShapeLayer.add(Animation.rightPlayToPause.keyframeAnimation(withDuration: animationDuration,
lineWidth: pauseLineWidth,
scale: pauseScale,
bounds: bounds), forKey: Animation.rightPlayToPause.key)
}
case .play:
if !animated || leftShapeLayer.animation(forKey: Animation.leftPlayToPause.key) != nil { // ongoing animation is cancelled
setModelToFinalPath()
} else {
setModelToFinalPath()
// left layer
leftShapeLayer.add(Animation.leftPauseToPlay.keyframeAnimation(withDuration: animationDuration,
lineWidth: pauseLineWidth,
scale: pauseScale,
bounds: bounds), forKey: Animation.leftPauseToPlay.key)
// right layer
rightShapeLayer.add(Animation.rightPauseToPlay.keyframeAnimation(withDuration: animationDuration,
lineWidth: pauseLineWidth,
scale: pauseScale,
bounds: bounds), forKey: Animation.rightPauseToPlay.key)
}
}
}
private func setModelToFinalPath() {
switch buttonAction {
case .pause:
leftShapeLayer.removeAllAnimations()
rightShapeLayer.removeAllAnimations()
leftShapeLayer.path = Animation.leftPlayToPause.finalFrame(lineWidth: pauseLineWidth, atScale: 1.0, bounds: bounds)
rightShapeLayer.path = Animation.rightPlayToPause.finalFrame(lineWidth: pauseLineWidth, atScale: 1.0, bounds: bounds)
case .play:
leftShapeLayer.removeAllAnimations()
rightShapeLayer.removeAllAnimations()
leftShapeLayer.path = Animation.leftPauseToPlay.finalFrame(lineWidth: pauseLineWidth, atScale: 1.0, bounds: bounds)
rightShapeLayer.path = Animation.rightPauseToPlay.finalFrame(lineWidth: pauseLineWidth, atScale: 1.0, bounds: bounds)
}
}
private func setup() {
backgroundColor = .clear
tintColor = UIColor.white
layer.addSublayer(leftShapeLayer)
layer.addSublayer(rightShapeLayer)
leftShapeLayer.path = Animation.leftPauseToPlay.finalFrame(lineWidth: pauseLineWidth, atScale: pauseScale, bounds: bounds)
rightShapeLayer.path = Animation.rightPauseToPlay.finalFrame(lineWidth: pauseLineWidth, atScale: pauseScale, bounds: bounds)
let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleGesture))
longPressGestureRecognizer.minimumPressDuration = 0.5
addGestureRecognizer(longPressGestureRecognizer)
}
@objc
func handleGesture(_ gesture: UIGestureRecognizer) {
switch gesture.state {
case .began:
timer = Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true, block: { [weak self] timer in
guard let `self` = self, timer.isValid else { return }
self.progress += 0.02
if self.progress > 1.0 {
self.timer?.invalidate()
self.timer = nil
self.setButtonAction(action: PlayAction.play, animated: true)
self.progress = 0.0
self.impactGenerator.impactOccurred()
self.reset?()
gesture.reset()
}
})
case .changed:
break
default:
timer?.invalidate()
timer = nil
progress = 0.0
}
}
public override func layoutSublayers(of layer: CALayer) {
super.layoutSublayers(of: layer)
leftShapeLayer.frame = bounds
rightShapeLayer.frame = bounds
switch buttonAction {
case .pause:
leftShapeLayer.path = Animation.leftPlayToPause.finalFrame(lineWidth: pauseLineWidth, atScale: 1.0, bounds: bounds)
rightShapeLayer.path = Animation.rightPlayToPause.finalFrame(lineWidth: pauseLineWidth, atScale: 1.0, bounds: bounds)
case .play:
leftShapeLayer.path = Animation.leftPauseToPlay.finalFrame(lineWidth: pauseLineWidth, atScale: 1.0, bounds: bounds)
rightShapeLayer.path = Animation.rightPauseToPlay.finalFrame(lineWidth: pauseLineWidth, atScale: 1.0, bounds: bounds)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment