443 lines
15 KiB
Swift
443 lines
15 KiB
Swift
//
|
||
// Swiftfin is subject to the terms of the Mozilla Public
|
||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||
//
|
||
// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
|
||
//
|
||
|
||
// Modification of https://github.com/zattoo/TvOSSlider
|
||
|
||
import SwiftUI
|
||
import UIKit
|
||
|
||
// TODO: Replace
|
||
|
||
private let trackViewHeight: CGFloat = 5
|
||
private let animationDuration: TimeInterval = 0.3
|
||
private let defaultValue: Float = 0
|
||
private let defaultMinimumValue: Float = 0
|
||
private let defaultMaximumValue: Float = 1
|
||
private let defaultIsContinuous: Bool = true
|
||
private let defaultThumbTintColor: UIColor = .white
|
||
private let defaultTrackColor: UIColor = .gray
|
||
private let defaultMininumTrackTintColor: UIColor = .blue
|
||
private let defaultFocusScaleFactor: CGFloat = 1.05
|
||
private let defaultStepValue: Float = 0.1
|
||
private let decelerationRate: Float = 0.92
|
||
private let decelerationMaxVelocity: Float = 1000
|
||
|
||
/// A control used to select a single value from a continuous range of values.
|
||
final class UITVOSSlider: UIControl {
|
||
|
||
/// The slider’s current value.
|
||
var value: Float {
|
||
get {
|
||
storedValue
|
||
}
|
||
set {
|
||
storedValue = min(maximumValue, newValue)
|
||
storedValue = max(minimumValue, storedValue)
|
||
|
||
var offset = trackView.bounds.width * CGFloat((storedValue - minimumValue) / (maximumValue - minimumValue))
|
||
offset = min(trackView.bounds.width, offset)
|
||
thumbViewCenterXConstraint.constant = offset
|
||
}
|
||
}
|
||
|
||
/// The minimum value of the slider.
|
||
var minimumValue: Float = defaultMinimumValue {
|
||
didSet {
|
||
value = max(value, minimumValue)
|
||
}
|
||
}
|
||
|
||
/// The maximum value of the slider.
|
||
var maximumValue: Float = defaultMaximumValue {
|
||
didSet {
|
||
value = min(value, maximumValue)
|
||
}
|
||
}
|
||
|
||
/// A Boolean value indicating whether changes in the slider’s value generate continuous update events.
|
||
var isContinuous: Bool = defaultIsContinuous
|
||
|
||
/// The color used to tint the default minimum track images.
|
||
var minimumTrackTintColor: UIColor? = defaultMininumTrackTintColor {
|
||
didSet {
|
||
minimumTrackView.backgroundColor = minimumTrackTintColor
|
||
}
|
||
}
|
||
|
||
/// The color used to tint the default maximum track images.
|
||
var maximumTrackTintColor: UIColor? {
|
||
didSet {
|
||
maximumTrackView.backgroundColor = maximumTrackTintColor
|
||
}
|
||
}
|
||
|
||
/// The color used to tint the default thumb images.
|
||
var thumbTintColor: UIColor = defaultThumbTintColor {
|
||
didSet {
|
||
thumbView.backgroundColor = thumbTintColor
|
||
}
|
||
}
|
||
|
||
/// Scale factor applied to the slider when receiving the focus
|
||
var focusScaleFactor: CGFloat = defaultFocusScaleFactor {
|
||
didSet {
|
||
updateStateDependantViews()
|
||
}
|
||
}
|
||
|
||
/// Value added or subtracted from the current value on steps left or right updates
|
||
var stepValue: Float = defaultStepValue
|
||
|
||
/// Damping value for panning gestures
|
||
var panDampingValue: Float = 5
|
||
|
||
// Size for thumb view
|
||
var thumbSize: CGFloat = 30
|
||
|
||
var fineTunningVelocityThreshold: Float = 600
|
||
|
||
/**
|
||
Sets the slider’s current value, allowing you to animate the change visually.
|
||
|
||
- Parameters:
|
||
- value: The new value to assign to the value property
|
||
- animated: Specify true to animate the change in value; otherwise, specify false to update the slider’s appearance immediately. Animations are performed asynchronously and do not block the calling thread.
|
||
*/
|
||
func setValue(_ value: Float, animated: Bool) {
|
||
self.value = value
|
||
stopDeceleratingTimer()
|
||
|
||
if animated {
|
||
UIView.animate(withDuration: animationDuration) {
|
||
self.setNeedsLayout()
|
||
self.layoutIfNeeded()
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
Assigns a minimum track image to the specified control states.
|
||
|
||
- Parameters:
|
||
- image: The minimum track image to associate with the specified states.
|
||
- state: The control state with which to associate the image.
|
||
*/
|
||
func setMinimumTrackImage(_ image: UIImage?, for state: UIControl.State) {
|
||
minimumTrackViewImages[state.rawValue] = image
|
||
updateStateDependantViews()
|
||
}
|
||
|
||
/**
|
||
Assigns a maximum track image to the specified control states.
|
||
|
||
- Parameters:
|
||
- image: The maximum track image to associate with the specified states.
|
||
- state: The control state with which to associate the image.
|
||
*/
|
||
func setMaximumTrackImage(_ image: UIImage?, for state: UIControl.State) {
|
||
maximumTrackViewImages[state.rawValue] = image
|
||
updateStateDependantViews()
|
||
}
|
||
|
||
/**
|
||
Assigns a thumb image to the specified control states.
|
||
|
||
- Parameters:
|
||
- image: The thumb image to associate with the specified states.
|
||
- state: The control state with which to associate the image.
|
||
*/
|
||
func setThumbImage(_ image: UIImage?, for state: UIControl.State) {
|
||
thumbViewImages[state.rawValue] = image
|
||
updateStateDependantViews()
|
||
}
|
||
|
||
// MARK: - Initializers
|
||
|
||
private var onEditingChanged: (Bool) -> Void
|
||
private var valueBinding: Binding<CGFloat>
|
||
|
||
init(
|
||
value: Binding<CGFloat>,
|
||
onEditingChanged: @escaping (Bool) -> Void
|
||
) {
|
||
self.onEditingChanged = onEditingChanged
|
||
self.valueBinding = value
|
||
|
||
super.init(frame: .zero)
|
||
|
||
setUpView()
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
deinit {
|
||
NotificationCenter.default.removeObserver(self)
|
||
}
|
||
|
||
// MARK: - UIControlStates
|
||
|
||
/// :nodoc:
|
||
override var isEnabled: Bool {
|
||
didSet {
|
||
panGestureRecognizer.isEnabled = isEnabled
|
||
updateStateDependantViews()
|
||
}
|
||
}
|
||
|
||
/// :nodoc:
|
||
override var isSelected: Bool {
|
||
didSet {
|
||
updateStateDependantViews()
|
||
}
|
||
}
|
||
|
||
/// :nodoc:
|
||
override var isHighlighted: Bool {
|
||
didSet {
|
||
updateStateDependantViews()
|
||
}
|
||
}
|
||
|
||
/// :nodoc:
|
||
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
|
||
coordinator.addCoordinatedAnimations({
|
||
self.updateStateDependantViews()
|
||
}, completion: nil)
|
||
}
|
||
|
||
// MARK: - Private
|
||
|
||
private typealias ControlState = UInt
|
||
|
||
private var storedValue: Float = defaultValue
|
||
|
||
private var thumbViewImages: [ControlState: UIImage] = [:]
|
||
private var thumbView: UIImageView!
|
||
|
||
private var trackViewImages: [ControlState: UIImage] = [:]
|
||
private var trackView: UIImageView!
|
||
|
||
private var minimumTrackViewImages: [ControlState: UIImage] = [:]
|
||
private var minimumTrackView: UIImageView!
|
||
|
||
private var maximumTrackViewImages: [ControlState: UIImage] = [:]
|
||
private var maximumTrackView: UIImageView!
|
||
|
||
private var panGestureRecognizer: UIPanGestureRecognizer!
|
||
private var leftTapGestureRecognizer: UITapGestureRecognizer!
|
||
private var rightTapGestureRecognizer: UITapGestureRecognizer!
|
||
|
||
private var thumbViewCenterXConstraint: NSLayoutConstraint!
|
||
|
||
private weak var deceleratingTimer: Timer?
|
||
private var deceleratingVelocity: Float = 0
|
||
|
||
private var thumbViewCenterXConstraintConstant: Float = 0
|
||
|
||
private func setUpView() {
|
||
setUpTrackView()
|
||
setUpMinimumTrackView()
|
||
setUpMaximumTrackView()
|
||
setUpThumbView()
|
||
|
||
setUpTrackViewConstraints()
|
||
setUpMinimumTrackViewConstraints()
|
||
setUpMaximumTrackViewConstraints()
|
||
setUpThumbViewConstraints()
|
||
|
||
setUpGestures()
|
||
|
||
updateStateDependantViews()
|
||
}
|
||
|
||
private func setUpThumbView() {
|
||
thumbView = UIImageView()
|
||
thumbView.layer.cornerRadius = thumbSize / 6
|
||
thumbView.backgroundColor = thumbTintColor
|
||
addSubview(thumbView)
|
||
}
|
||
|
||
private func setUpTrackView() {
|
||
trackView = UIImageView()
|
||
trackView.layer.cornerRadius = trackViewHeight / 2
|
||
trackView.backgroundColor = defaultTrackColor.withAlphaComponent(0.3)
|
||
addSubview(trackView)
|
||
}
|
||
|
||
private func setUpMinimumTrackView() {
|
||
minimumTrackView = UIImageView()
|
||
minimumTrackView.layer.cornerRadius = trackViewHeight / 2
|
||
minimumTrackView.backgroundColor = minimumTrackTintColor
|
||
addSubview(minimumTrackView)
|
||
}
|
||
|
||
private func setUpMaximumTrackView() {
|
||
maximumTrackView = UIImageView()
|
||
maximumTrackView.layer.cornerRadius = trackViewHeight / 2
|
||
maximumTrackView.backgroundColor = maximumTrackTintColor
|
||
addSubview(maximumTrackView)
|
||
}
|
||
|
||
private func setUpTrackViewConstraints() {
|
||
trackView.translatesAutoresizingMaskIntoConstraints = false
|
||
trackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
|
||
trackView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
|
||
trackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
|
||
trackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true
|
||
}
|
||
|
||
private func setUpMinimumTrackViewConstraints() {
|
||
minimumTrackView.translatesAutoresizingMaskIntoConstraints = false
|
||
minimumTrackView.leadingAnchor.constraint(equalTo: trackView.leadingAnchor).isActive = true
|
||
minimumTrackView.trailingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true
|
||
minimumTrackView.centerYAnchor.constraint(equalTo: trackView.centerYAnchor).isActive = true
|
||
minimumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true
|
||
}
|
||
|
||
private func setUpMaximumTrackViewConstraints() {
|
||
maximumTrackView.translatesAutoresizingMaskIntoConstraints = false
|
||
maximumTrackView.leadingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true
|
||
maximumTrackView.trailingAnchor.constraint(equalTo: trackView.trailingAnchor).isActive = true
|
||
maximumTrackView.centerYAnchor.constraint(equalTo: trackView.centerYAnchor).isActive = true
|
||
maximumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true
|
||
}
|
||
|
||
private func setUpThumbViewConstraints() {
|
||
thumbView.translatesAutoresizingMaskIntoConstraints = false
|
||
thumbView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
|
||
thumbView.widthAnchor.constraint(equalToConstant: thumbSize / 3).isActive = true
|
||
thumbView.heightAnchor.constraint(equalToConstant: thumbSize).isActive = true
|
||
thumbViewCenterXConstraint = thumbView.centerXAnchor.constraint(equalTo: trackView.leadingAnchor, constant: CGFloat(value))
|
||
thumbViewCenterXConstraint.isActive = true
|
||
}
|
||
|
||
private func setUpGestures() {
|
||
panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureWasTriggered(panGestureRecognizer:)))
|
||
addGestureRecognizer(panGestureRecognizer)
|
||
|
||
leftTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(leftTapWasTriggered))
|
||
leftTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.leftArrow.rawValue)]
|
||
leftTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)]
|
||
addGestureRecognizer(leftTapGestureRecognizer)
|
||
|
||
rightTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(rightTapWasTriggered))
|
||
rightTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.rightArrow.rawValue)]
|
||
rightTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)]
|
||
addGestureRecognizer(rightTapGestureRecognizer)
|
||
}
|
||
|
||
private func updateStateDependantViews() {
|
||
thumbView.image = thumbViewImages[state.rawValue] ?? thumbViewImages[UIControl.State.normal.rawValue]
|
||
|
||
if isFocused {
|
||
thumbView.transform = CGAffineTransform(scaleX: focusScaleFactor, y: focusScaleFactor)
|
||
} else {
|
||
thumbView.transform = CGAffineTransform.identity
|
||
}
|
||
}
|
||
|
||
@objc
|
||
private func handleDeceleratingTimer(timer: Timer) {
|
||
let centerX = thumbViewCenterXConstraintConstant + deceleratingVelocity * 0.01
|
||
let percent = centerX / Float(trackView.frame.width)
|
||
value = minimumValue + ((maximumValue - minimumValue) * percent)
|
||
|
||
if isContinuous {
|
||
sendActions(for: .valueChanged)
|
||
}
|
||
|
||
thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant)
|
||
|
||
deceleratingVelocity *= decelerationRate
|
||
if !isFocused || abs(deceleratingVelocity) < 1 {
|
||
stopDeceleratingTimer()
|
||
}
|
||
|
||
valueBinding.wrappedValue = CGFloat(percent)
|
||
onEditingChanged(false)
|
||
}
|
||
|
||
private func stopDeceleratingTimer() {
|
||
deceleratingTimer?.invalidate()
|
||
deceleratingTimer = nil
|
||
deceleratingVelocity = 0
|
||
sendActions(for: .valueChanged)
|
||
}
|
||
|
||
private func isVerticalGesture(_ recognizer: UIPanGestureRecognizer) -> Bool {
|
||
let translation = recognizer.translation(in: self)
|
||
if abs(translation.y) > abs(translation.x) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// MARK: - Actions
|
||
|
||
@objc
|
||
private func panGestureWasTriggered(panGestureRecognizer: UIPanGestureRecognizer) {
|
||
|
||
guard !isVerticalGesture(panGestureRecognizer) else { return }
|
||
|
||
let translation = Float(panGestureRecognizer.translation(in: self).x)
|
||
let velocity = Float(panGestureRecognizer.velocity(in: self).x)
|
||
|
||
switch panGestureRecognizer.state {
|
||
case .began:
|
||
onEditingChanged(true)
|
||
|
||
stopDeceleratingTimer()
|
||
thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant)
|
||
case .changed:
|
||
let centerX = thumbViewCenterXConstraintConstant + translation / panDampingValue
|
||
let percent = centerX / Float(trackView.frame.width)
|
||
value = minimumValue + ((maximumValue - minimumValue) * percent)
|
||
if isContinuous {
|
||
sendActions(for: .valueChanged)
|
||
}
|
||
|
||
valueBinding.wrappedValue = CGFloat(percent)
|
||
case .ended, .cancelled:
|
||
|
||
thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant)
|
||
|
||
if abs(velocity) > fineTunningVelocityThreshold {
|
||
let direction: Float = velocity > 0 ? 1 : -1
|
||
deceleratingVelocity = abs(velocity) > decelerationMaxVelocity ? decelerationMaxVelocity * direction : velocity
|
||
deceleratingTimer = Timer.scheduledTimer(
|
||
timeInterval: 0.01,
|
||
target: self,
|
||
selector: #selector(handleDeceleratingTimer(timer:)),
|
||
userInfo: nil,
|
||
repeats: true
|
||
)
|
||
} else {
|
||
onEditingChanged(false)
|
||
stopDeceleratingTimer()
|
||
}
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
@objc
|
||
private func leftTapWasTriggered() {
|
||
// setValue(value-stepValue, animated: true)
|
||
// viewModel.playerOverlayDelegate?.didSelectBackward()
|
||
}
|
||
|
||
@objc
|
||
private func rightTapWasTriggered() {
|
||
// setValue(value+stepValue, animated: true)
|
||
// viewModel.playerOverlayDelegate?.didSelectForward()
|
||
}
|
||
}
|