635 lines
22 KiB
Swift
635 lines
22 KiB
Swift
//
|
||
// SOSAnimationViews.swift
|
||
// QuickLocation
|
||
//
|
||
|
||
import UIKit
|
||
|
||
enum SOSConfiguration {
|
||
static let countdownDuration: TimeInterval = 5
|
||
static let slideCancelCompletionRatio: CGFloat = 0.9
|
||
|
||
static var countdownSeconds: Int {
|
||
Int(ceil(countdownDuration))
|
||
}
|
||
|
||
static var countdownDescription: String {
|
||
"\(countdownSeconds)秒后,会将你的SOS和位置发送到你的圈子和紧急联系人。"
|
||
}
|
||
}
|
||
|
||
final class SOSPulseButtonView: UIView {
|
||
|
||
var onTap: (() -> Void)?
|
||
|
||
private let buttonImageView = UIImageView(image: UIImage(named: "SOS/sos_main_button"))
|
||
private let titleImageView = UIImageView(image: UIImage(named: "SOS/sos_title"))
|
||
private let handImageView = UIImageView(image: UIImage(named: "SOS/sos_tap_hand"))
|
||
private let tapButton = UIButton(type: .custom)
|
||
private let rippleLayers = [CAShapeLayer(), CAShapeLayer()]
|
||
private var shouldAnimate = true
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = .clear
|
||
isAccessibilityElement = false
|
||
|
||
rippleLayers.forEach {
|
||
$0.fillColor = UIColor.clear.cgColor
|
||
$0.strokeColor = UIColor(hexStr: "#FF7378").cgColor
|
||
$0.lineWidth = 1.5
|
||
$0.opacity = 0
|
||
layer.addSublayer($0)
|
||
}
|
||
|
||
buttonImageView.contentMode = .scaleAspectFit
|
||
addSubview(buttonImageView)
|
||
|
||
titleImageView.contentMode = .scaleAspectFit
|
||
addSubview(titleImageView)
|
||
|
||
handImageView.contentMode = .scaleAspectFit
|
||
addSubview(handImageView)
|
||
|
||
tapButton.accessibilityLabel = "发送SOS"
|
||
tapButton.addTarget(self, action: #selector(handleTap), for: .touchUpInside)
|
||
addSubview(tapButton)
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override var intrinsicContentSize: CGSize {
|
||
CGSize(width: 345, height: 345)
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
|
||
let side = min(bounds.width, bounds.height)
|
||
let center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||
let buttonSide = side * 0.74
|
||
buttonImageView.frame = CGRect(
|
||
x: center.x - buttonSide / 2,
|
||
y: center.y - buttonSide / 2,
|
||
width: buttonSide,
|
||
height: buttonSide
|
||
)
|
||
|
||
let rippleCenter = buttonImageView.center
|
||
let rippleStartRadius = buttonImageView.frame.width / 2
|
||
rippleLayers.forEach { rippleLayer in
|
||
rippleLayer.frame = bounds
|
||
rippleLayer.path = UIBezierPath(
|
||
arcCenter: rippleCenter,
|
||
radius: rippleStartRadius,
|
||
startAngle: 0,
|
||
endAngle: .pi * 2,
|
||
clockwise: true
|
||
).cgPath
|
||
}
|
||
|
||
let titleWidth = buttonSide * 0.49
|
||
let titleAspectRatio = titleImageView.image.map { $0.size.height / $0.size.width } ?? 0.32
|
||
let titleHeight = titleWidth * titleAspectRatio
|
||
let titleCenterY = handImageView.isHidden
|
||
? center.y
|
||
: center.y - buttonSide * 0.13
|
||
titleImageView.frame = CGRect(
|
||
x: center.x - titleWidth / 2,
|
||
y: titleCenterY - titleHeight / 2,
|
||
width: titleWidth,
|
||
height: titleHeight
|
||
)
|
||
|
||
let handSize = CGSize(width: side * 0.09, height: side * 0.14)
|
||
handImageView.frame = CGRect(
|
||
x: center.x - handSize.width / 2,
|
||
y: center.y + buttonSide * 0.13,
|
||
width: handSize.width,
|
||
height: handSize.height
|
||
)
|
||
tapButton.frame = bounds
|
||
}
|
||
|
||
override func didMoveToWindow() {
|
||
super.didMoveToWindow()
|
||
window == nil ? stopAnimations() : startAnimationsIfNeeded()
|
||
}
|
||
|
||
func setShowsHand(_ showsHand: Bool) {
|
||
handImageView.isHidden = !showsHand
|
||
setNeedsLayout()
|
||
if showsHand {
|
||
startAnimationsIfNeeded()
|
||
} else {
|
||
handImageView.layer.removeAnimation(forKey: "sos.hand.tap")
|
||
}
|
||
}
|
||
|
||
func setAnimationEnabled(_ enabled: Bool) {
|
||
shouldAnimate = enabled
|
||
enabled ? startAnimationsIfNeeded() : stopAnimations()
|
||
}
|
||
|
||
private func startAnimationsIfNeeded() {
|
||
guard shouldAnimate, window != nil, !UIAccessibility.isReduceMotionEnabled else {
|
||
stopAnimations()
|
||
return
|
||
}
|
||
|
||
let travelDuration = 1.35
|
||
let staggerDuration = travelDuration * 0.85
|
||
let cycleDuration = staggerDuration * Double(rippleLayers.count)
|
||
let travelProgress = NSNumber(value: travelDuration / cycleDuration)
|
||
let baseTime = CACurrentMediaTime()
|
||
for (index, rippleLayer) in rippleLayers.enumerated() {
|
||
guard rippleLayer.animation(forKey: "sos.ripple") == nil else { continue }
|
||
|
||
let rippleScale = CAKeyframeAnimation(keyPath: "transform.scale")
|
||
rippleScale.values = [1.0, 1.42, 1.42]
|
||
rippleScale.keyTimes = [0, travelProgress, 1]
|
||
|
||
let opacity = CAKeyframeAnimation(keyPath: "opacity")
|
||
opacity.values = [0.58, 0, 0]
|
||
opacity.keyTimes = [0, travelProgress, 1]
|
||
|
||
let rippleGroup = CAAnimationGroup()
|
||
rippleGroup.animations = [rippleScale, opacity]
|
||
rippleGroup.duration = cycleDuration
|
||
rippleGroup.beginTime = baseTime + Double(index) * staggerDuration
|
||
rippleGroup.repeatCount = Float.infinity
|
||
rippleGroup.isRemovedOnCompletion = false
|
||
rippleLayer.add(rippleGroup, forKey: "sos.ripple")
|
||
}
|
||
|
||
guard !handImageView.isHidden,
|
||
handImageView.layer.animation(forKey: "sos.hand.tap") == nil else { return }
|
||
|
||
let translation = CAKeyframeAnimation(keyPath: "transform.translation.y")
|
||
translation.values = [0, 5, 5, 0]
|
||
translation.keyTimes = [0, 0.35, 0.58, 1]
|
||
|
||
let handScale = CAKeyframeAnimation(keyPath: "transform.scale")
|
||
handScale.values = [1, 0.9, 0.9, 1]
|
||
handScale.keyTimes = [0, 0.35, 0.58, 1]
|
||
|
||
let handGroup = CAAnimationGroup()
|
||
handGroup.animations = [translation, handScale]
|
||
handGroup.duration = 1.15
|
||
handGroup.repeatCount = Float.infinity
|
||
handImageView.layer.add(handGroup, forKey: "sos.hand.tap")
|
||
}
|
||
|
||
private func stopAnimations() {
|
||
rippleLayers.forEach { $0.removeAnimation(forKey: "sos.ripple") }
|
||
handImageView.layer.removeAnimation(forKey: "sos.hand.tap")
|
||
}
|
||
|
||
@objc private func handleTap() {
|
||
onTap?()
|
||
}
|
||
}
|
||
|
||
final class SOSCountdownRingView: UIView {
|
||
|
||
var onCompleted: (() -> Void)?
|
||
|
||
private let trackLayer = CAShapeLayer()
|
||
private let progressMaskLayer = CAShapeLayer()
|
||
private let progressGradientLayer = CAGradientLayer()
|
||
private let rippleLayers = [CAShapeLayer(), CAShapeLayer()]
|
||
private let digitStack = UIStackView()
|
||
private let alarmContainer = UIView()
|
||
private let alarmRaysView = UIImageView(image: UIImage(named: "SOS/sos_alarm_rays"))
|
||
private let alarmIconView = UIImageView(image: UIImage(named: "SOS/sos_alarm_icon"))
|
||
|
||
private var countdownTimer: Timer?
|
||
private var deadline: Date?
|
||
private var duration = SOSConfiguration.countdownDuration
|
||
private var lastDisplayedValue = -1
|
||
private var completionDelivered = false
|
||
private var progress: CGFloat = 1
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = .clear
|
||
|
||
rippleLayers.forEach {
|
||
$0.fillColor = UIColor.clear.cgColor
|
||
$0.strokeColor = UIColor(hexStr: "#F4B5EE").cgColor
|
||
$0.lineWidth = 1.5
|
||
$0.opacity = 0
|
||
layer.addSublayer($0)
|
||
}
|
||
|
||
trackLayer.fillColor = UIColor.clear.cgColor
|
||
trackLayer.strokeColor = UIColor(hexStr: "#EEE8FF").cgColor
|
||
trackLayer.lineCap = .butt
|
||
layer.addSublayer(trackLayer)
|
||
|
||
progressGradientLayer.type = .conic
|
||
progressGradientLayer.colors = [
|
||
UIColor(hexStr: "#C9B7FF").cgColor,
|
||
UIColor(hexStr: "#9C5AFF").cgColor,
|
||
UIColor(hexStr: "#D72DD6").cgColor,
|
||
UIColor(hexStr: "#C9B7FF").cgColor
|
||
]
|
||
progressGradientLayer.locations = [0, 0.38, 0.76, 1]
|
||
progressGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
|
||
progressGradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
|
||
progressGradientLayer.mask = progressMaskLayer
|
||
layer.addSublayer(progressGradientLayer)
|
||
|
||
digitStack.axis = .horizontal
|
||
digitStack.alignment = .center
|
||
digitStack.distribution = .fill
|
||
digitStack.spacing = 2
|
||
addSubview(digitStack)
|
||
|
||
alarmIconView.contentMode = .scaleAspectFit
|
||
alarmRaysView.contentMode = .scaleAspectFit
|
||
alarmContainer.addSubview(alarmIconView)
|
||
alarmContainer.addSubview(alarmRaysView)
|
||
addSubview(alarmContainer)
|
||
|
||
render(progress: 1, value: SOSConfiguration.countdownSeconds)
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override var intrinsicContentSize: CGSize {
|
||
CGSize(width: 345, height: 345)
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
|
||
let side = min(bounds.width, bounds.height)
|
||
let center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||
let ringDiameter = side * 0.64
|
||
let ringRadius = ringDiameter / 2
|
||
let lineWidth = max(18, side * 0.064)
|
||
rippleLayers.forEach { rippleLayer in
|
||
rippleLayer.frame = bounds
|
||
rippleLayer.path = UIBezierPath(
|
||
arcCenter: center,
|
||
radius: ringRadius + lineWidth / 2,
|
||
startAngle: 0,
|
||
endAngle: .pi * 2,
|
||
clockwise: true
|
||
).cgPath
|
||
}
|
||
|
||
let ringPath = UIBezierPath(
|
||
arcCenter: center,
|
||
radius: ringRadius,
|
||
startAngle: -.pi / 2,
|
||
endAngle: .pi * 1.5,
|
||
clockwise: true
|
||
)
|
||
|
||
trackLayer.frame = bounds
|
||
trackLayer.path = ringPath.cgPath
|
||
trackLayer.lineWidth = lineWidth
|
||
|
||
progressMaskLayer.frame = bounds
|
||
progressMaskLayer.path = ringPath.cgPath
|
||
progressMaskLayer.fillColor = UIColor.clear.cgColor
|
||
progressMaskLayer.strokeColor = UIColor.black.cgColor
|
||
progressMaskLayer.lineWidth = lineWidth
|
||
progressMaskLayer.lineCap = .butt
|
||
progressGradientLayer.frame = bounds
|
||
|
||
layoutDigitStack(at: center)
|
||
|
||
let alarmSide = side * 0.15
|
||
let alarmSize = CGSize(width: alarmSide, height: alarmSide)
|
||
alarmContainer.bounds = CGRect(origin: .zero, size: alarmSize)
|
||
alarmIconView.frame = alarmContainer.bounds
|
||
alarmRaysView.frame = alarmContainer.bounds
|
||
updateAlarmPosition(progress: progress)
|
||
}
|
||
|
||
func start(duration: TimeInterval = SOSConfiguration.countdownDuration) {
|
||
stop(reset: false)
|
||
self.duration = max(0.1, duration)
|
||
deadline = Date().addingTimeInterval(self.duration)
|
||
completionDelivered = false
|
||
lastDisplayedValue = -1
|
||
render(progress: 1, value: Int(ceil(self.duration)))
|
||
startAlarmAnimation()
|
||
|
||
let timer = Timer(timeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
|
||
self?.updateCountdown()
|
||
}
|
||
RunLoop.main.add(timer, forMode: .common)
|
||
countdownTimer = timer
|
||
}
|
||
|
||
func stop(reset: Bool = true) {
|
||
countdownTimer?.invalidate()
|
||
countdownTimer = nil
|
||
deadline = nil
|
||
rippleLayers.forEach { $0.removeAnimation(forKey: "sos.countdown.ripple") }
|
||
alarmRaysView.layer.removeAnimation(forKey: "sos.alarm.rays")
|
||
alarmIconView.layer.removeAnimation(forKey: "sos.alarm.icon")
|
||
if reset {
|
||
completionDelivered = false
|
||
lastDisplayedValue = -1
|
||
render(progress: 1, value: SOSConfiguration.countdownSeconds)
|
||
}
|
||
}
|
||
|
||
private func updateCountdown() {
|
||
guard let deadline else { return }
|
||
let remaining = max(0, deadline.timeIntervalSinceNow)
|
||
let progress = CGFloat(min(1, remaining / duration))
|
||
let value = max(0, min(Int(ceil(duration)), Int(ceil(remaining))))
|
||
render(progress: progress, value: value)
|
||
|
||
guard remaining <= 0, !completionDelivered else { return }
|
||
completionDelivered = true
|
||
countdownTimer?.invalidate()
|
||
countdownTimer = nil
|
||
self.deadline = nil
|
||
rippleLayers.forEach { $0.removeAnimation(forKey: "sos.countdown.ripple") }
|
||
alarmRaysView.layer.removeAnimation(forKey: "sos.alarm.rays")
|
||
alarmIconView.layer.removeAnimation(forKey: "sos.alarm.icon")
|
||
onCompleted?()
|
||
}
|
||
|
||
private func render(progress: CGFloat, value: Int) {
|
||
self.progress = progress
|
||
CATransaction.begin()
|
||
CATransaction.setDisableActions(true)
|
||
progressMaskLayer.strokeStart = 1 - progress
|
||
progressMaskLayer.strokeEnd = 1
|
||
CATransaction.commit()
|
||
updateAlarmPosition(progress: progress)
|
||
|
||
guard value != lastDisplayedValue else { return }
|
||
lastDisplayedValue = value
|
||
digitStack.arrangedSubviews.forEach {
|
||
digitStack.removeArrangedSubview($0)
|
||
$0.removeFromSuperview()
|
||
}
|
||
String(value).forEach { character in
|
||
let imageView = UIImageView(image: UIImage(named: "SOS/sos_countdown_\(character)"))
|
||
imageView.contentMode = .scaleAspectFit
|
||
imageView.widthAnchor.constraint(equalToConstant: 42).isActive = true
|
||
imageView.heightAnchor.constraint(equalToConstant: 58).isActive = true
|
||
digitStack.addArrangedSubview(imageView)
|
||
}
|
||
layoutDigitStack(at: CGPoint(x: bounds.midX, y: bounds.midY))
|
||
bringSubviewToFront(digitStack)
|
||
}
|
||
|
||
private func updateAlarmPosition(progress: CGFloat) {
|
||
guard bounds.width > 0, bounds.height > 0 else { return }
|
||
let side = min(bounds.width, bounds.height)
|
||
let radius = side * 0.64 / 2
|
||
let angle = -.pi / 2 + .pi * 2 * (1 - progress)
|
||
alarmContainer.center = CGPoint(
|
||
x: bounds.midX + cos(angle) * radius,
|
||
y: bounds.midY + sin(angle) * radius - 5
|
||
)
|
||
bringSubviewToFront(alarmContainer)
|
||
}
|
||
|
||
private func layoutDigitStack(at center: CGPoint) {
|
||
let digitWidth: CGFloat = 42
|
||
let digitHeight: CGFloat = 58
|
||
let count = digitStack.arrangedSubviews.count
|
||
let spacingWidth = CGFloat(max(0, count - 1)) * digitStack.spacing
|
||
digitStack.bounds = CGRect(
|
||
x: 0,
|
||
y: 0,
|
||
width: CGFloat(count) * digitWidth + spacingWidth,
|
||
height: digitHeight
|
||
)
|
||
digitStack.center = center
|
||
digitStack.setNeedsLayout()
|
||
digitStack.layoutIfNeeded()
|
||
}
|
||
|
||
private func startAlarmAnimation() {
|
||
guard !UIAccessibility.isReduceMotionEnabled else { return }
|
||
|
||
let travelDuration = 1.35
|
||
let staggerDuration = travelDuration * 0.85
|
||
let cycleDuration = staggerDuration * Double(rippleLayers.count)
|
||
let travelProgress = NSNumber(value: travelDuration / cycleDuration)
|
||
let baseTime = CACurrentMediaTime()
|
||
for (index, rippleLayer) in rippleLayers.enumerated() {
|
||
let rippleScale = CAKeyframeAnimation(keyPath: "transform.scale")
|
||
rippleScale.values = [1.0, 1.55, 1.55]
|
||
rippleScale.keyTimes = [0, travelProgress, 1]
|
||
|
||
let rippleOpacity = CAKeyframeAnimation(keyPath: "opacity")
|
||
rippleOpacity.values = [0.58, 0, 0]
|
||
rippleOpacity.keyTimes = [0, travelProgress, 1]
|
||
|
||
let rippleGroup = CAAnimationGroup()
|
||
rippleGroup.animations = [rippleScale, rippleOpacity]
|
||
rippleGroup.duration = cycleDuration
|
||
rippleGroup.beginTime = baseTime + Double(index) * staggerDuration
|
||
rippleGroup.repeatCount = .infinity
|
||
rippleGroup.isRemovedOnCompletion = false
|
||
rippleLayer.add(rippleGroup, forKey: "sos.countdown.ripple")
|
||
}
|
||
|
||
let opacity = CAKeyframeAnimation(keyPath: "opacity")
|
||
opacity.values = [0.25, 1, 0.25]
|
||
opacity.keyTimes = [0, 0.5, 1]
|
||
|
||
let scale = CAKeyframeAnimation(keyPath: "transform.scale")
|
||
scale.values = [0.88, 1.08, 0.88]
|
||
scale.keyTimes = [0, 0.5, 1]
|
||
|
||
let group = CAAnimationGroup()
|
||
group.animations = [opacity, scale]
|
||
group.duration = 0.62
|
||
group.repeatCount = .infinity
|
||
alarmRaysView.layer.add(group, forKey: "sos.alarm.rays")
|
||
|
||
let rotation = CAKeyframeAnimation(keyPath: "transform.rotation.z")
|
||
rotation.values = [-CGFloat.pi / 24, CGFloat.pi / 24, -CGFloat.pi / 24]
|
||
rotation.keyTimes = [0, 0.5, 1]
|
||
|
||
let iconGroup = CAAnimationGroup()
|
||
iconGroup.animations = [rotation]
|
||
iconGroup.duration = 0.62
|
||
iconGroup.repeatCount = .infinity
|
||
iconGroup.isRemovedOnCompletion = false
|
||
alarmIconView.layer.add(iconGroup, forKey: "sos.alarm.icon")
|
||
}
|
||
|
||
deinit {
|
||
countdownTimer?.invalidate()
|
||
}
|
||
}
|
||
|
||
final class SOSSlideCancelView: UIView {
|
||
|
||
enum Style {
|
||
case practiceCountdown
|
||
case countdown
|
||
case active
|
||
}
|
||
|
||
var onCompleted: (() -> Void)?
|
||
|
||
private let gradientLayer = CAGradientLayer()
|
||
private let titleLabel = UILabel()
|
||
private let thumbView = UIImageView(image: UIImage(named: "SOS/sos_slider_arrow"))
|
||
private var style: Style = .countdown
|
||
private var startX: CGFloat = 3
|
||
private var isDragging = false
|
||
private var isLocked = false
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
layer.insertSublayer(gradientLayer, at: 0)
|
||
clipsToBounds = true
|
||
|
||
titleLabel.text = "滑动取消sos"
|
||
titleLabel.textColor = .white
|
||
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
||
titleLabel.textAlignment = .center
|
||
addSubview(titleLabel)
|
||
|
||
thumbView.contentMode = .scaleAspectFit
|
||
thumbView.isUserInteractionEnabled = true
|
||
addSubview(thumbView)
|
||
|
||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
||
thumbView.addGestureRecognizer(pan)
|
||
isAccessibilityElement = true
|
||
accessibilityLabel = "滑动取消SOS"
|
||
accessibilityTraits = .adjustable
|
||
|
||
configure(style: .countdown)
|
||
}
|
||
|
||
convenience init(thumbImageName: String) {
|
||
self.init(frame: .zero)
|
||
thumbView.image = UIImage(named: thumbImageName)
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
layer.cornerRadius = bounds.height / 2
|
||
gradientLayer.frame = bounds
|
||
gradientLayer.cornerRadius = bounds.height / 2
|
||
titleLabel.frame = bounds
|
||
|
||
let thumbSide = max(0, bounds.height - 10)
|
||
thumbView.frame = CGRect(x: 10, y: 5, width: thumbSide, height: thumbSide)
|
||
}
|
||
|
||
func configure(style: Style) {
|
||
self.style = style
|
||
switch style {
|
||
case .practiceCountdown:
|
||
gradientLayer.colors = [
|
||
UIColor(hexStr: "#08ADEF").cgColor,
|
||
UIColor(hexStr: "#43C7F8").cgColor
|
||
]
|
||
case .countdown, .active:
|
||
gradientLayer.colors = [
|
||
UIColor(hexStr: "#293445").cgColor,
|
||
UIColor(hexStr: "#293445").cgColor
|
||
]
|
||
}
|
||
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
|
||
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
|
||
reset(animated: false)
|
||
}
|
||
|
||
func setInteractionEnabled(_ enabled: Bool) {
|
||
isLocked = !enabled
|
||
alpha = enabled ? 1 : 0.65
|
||
thumbView.isUserInteractionEnabled = enabled
|
||
}
|
||
|
||
func reset(animated: Bool = true) {
|
||
isLocked = false
|
||
isDragging = false
|
||
thumbView.isUserInteractionEnabled = true
|
||
alpha = 1
|
||
let changes = {
|
||
self.thumbView.frame.origin.x = 2
|
||
}
|
||
if animated {
|
||
UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseOut], animations: changes)
|
||
} else {
|
||
changes()
|
||
}
|
||
}
|
||
|
||
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||
guard !isLocked else { return }
|
||
let minimumX: CGFloat = 2
|
||
let maxX = max(minimumX, bounds.width - thumbView.bounds.width - minimumX)
|
||
|
||
switch gesture.state {
|
||
case .began:
|
||
isDragging = true
|
||
startX = thumbView.frame.minX
|
||
case .changed:
|
||
let translation = gesture.translation(in: self)
|
||
thumbView.frame.origin.x = min(maxX, max(minimumX, startX + translation.x))
|
||
case .ended, .cancelled, .failed:
|
||
isDragging = false
|
||
let completionX = minimumX + (maxX - minimumX) * SOSConfiguration.slideCancelCompletionRatio
|
||
if thumbView.frame.minX >= completionX {
|
||
isLocked = true
|
||
thumbView.isUserInteractionEnabled = false
|
||
onCompleted?()
|
||
} else {
|
||
reset()
|
||
}
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
final class SOSGradientButton: UIButton {
|
||
|
||
private let gradientLayer = CAGradientLayer()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
gradientLayer.colors = [
|
||
UIColor(hexStr: "#05AEF2").cgColor,
|
||
UIColor(hexStr: "#42C5F7").cgColor
|
||
]
|
||
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
|
||
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
|
||
layer.insertSublayer(gradientLayer, at: 0)
|
||
setTitleColor(.white, for: .normal)
|
||
titleLabel?.font = .systemFont(ofSize: 18, weight: .bold)
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
gradientLayer.frame = bounds
|
||
layer.cornerRadius = bounds.height / 2
|
||
gradientLayer.cornerRadius = bounds.height / 2
|
||
clipsToBounds = true
|
||
}
|
||
}
|