Instantly share code, notes, and snippets.
Created
July 20, 2019 10:26
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
Save trilliwon/7571706382dc1813b9945e6f5a541fc4 to your computer and use it in GitHub Desktop.
Play pause button
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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