parent
9355fa5d15
commit
5146380302
@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "play_ thumb_icon.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 181 B |
@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "player_shuffle_random.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "player_shuffle_singleLoop.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 2.2 KiB |
@ -0,0 +1,56 @@
|
||||
//
|
||||
// SliderControl.swift
|
||||
// SliderControl
|
||||
//
|
||||
// Created by Alexander Chekel on 19.09.2023.
|
||||
// Copyright © 2023 Alexander Chekel. All rights reserved.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
public class SliderControlValueSubscription<S: Subscriber>: Subscription where S.Input == Float {
|
||||
private let subscriber: S
|
||||
private weak var control: SliderControl?
|
||||
|
||||
public init(subscriber: S, control: SliderControl) {
|
||||
self.subscriber = subscriber
|
||||
self.control = control
|
||||
|
||||
self.control?.addTarget(self, action: #selector(sliderValueChanged), for: .valueChanged)
|
||||
}
|
||||
|
||||
public func request(_ demand: Subscribers.Demand) {
|
||||
// Cannot request more .valueChanged events
|
||||
}
|
||||
|
||||
public func cancel() {
|
||||
control?.removeTarget(self, action: #selector(sliderValueChanged), for: .valueChanged)
|
||||
control = nil
|
||||
}
|
||||
|
||||
@objc private func sliderValueChanged(sender: SliderControl) {
|
||||
_ = subscriber.receive(sender.value)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SliderControlValuePublisher: Publisher {
|
||||
public typealias Output = Float
|
||||
public typealias Failure = Never
|
||||
|
||||
private weak var control: SliderControl?
|
||||
|
||||
public init(control: SliderControl) {
|
||||
self.control = control
|
||||
}
|
||||
|
||||
public func receive<S>(subscriber: S) where S : Subscriber, S.Failure == Never, S.Input == Float {
|
||||
guard let control else {
|
||||
subscriber.receive(completion: .finished)
|
||||
return
|
||||
}
|
||||
|
||||
let subscription = SliderControlValueSubscription(subscriber: subscriber, control: control)
|
||||
subscriber.receive(subscription: subscription)
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
//
|
||||
// NSLayoutConstraint+Multiplier.swift
|
||||
// SliderControl
|
||||
//
|
||||
// Created by Alexander Chekel on 09.09.2023.
|
||||
// Copyright © 2023 Alexander Chekel. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension NSLayoutConstraint {
|
||||
func constraintWithMultiplier(_ newMultiplier: CGFloat) -> NSLayoutConstraint {
|
||||
let shouldActivate = isActive
|
||||
if shouldActivate {
|
||||
NSLayoutConstraint.deactivate([self])
|
||||
}
|
||||
|
||||
let updatedConstraint = NSLayoutConstraint(
|
||||
item: firstItem as Any,
|
||||
attribute: firstAttribute,
|
||||
relatedBy: relation,
|
||||
toItem: secondItem,
|
||||
attribute: secondAttribute,
|
||||
multiplier: newMultiplier,
|
||||
constant: constant
|
||||
)
|
||||
updatedConstraint.priority = priority
|
||||
updatedConstraint.shouldBeArchived = shouldBeArchived
|
||||
updatedConstraint.identifier = identifier
|
||||
|
||||
if shouldActivate {
|
||||
NSLayoutConstraint.activate([updatedConstraint])
|
||||
}
|
||||
|
||||
return updatedConstraint
|
||||
}
|
||||
}
|
@ -0,0 +1,270 @@
|
||||
//
|
||||
// SliderControl.swift
|
||||
// SliderControl
|
||||
//
|
||||
// Created by Alexander Chekel on 09.09.2023.
|
||||
// Copyright © 2023 Alexander Chekel. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// Implements a slider control similar to one found in Apple Music on iOS 16.
|
||||
open class SliderControl: UIControl {
|
||||
/// Indicates whether changes in the slider's value generate continuous update events.
|
||||
/// Default value of this property is `true`.
|
||||
public var isContinuous: Bool = true
|
||||
/// A layout guide that follows track size changes in different states.
|
||||
public let trackLayoutGuide: UILayoutGuide = .init()
|
||||
/// Indicates whether slider should provide haptic feedback upon reaching minimum or maximum values.
|
||||
/// Default value of this property is `true`.
|
||||
open var providesHapticFeedback: Bool = true
|
||||
/// Feedback generator used to provide haptic feedback when slider reaches minimum or maximum value.
|
||||
/// Default value of this property is `UIImpactFeedbackGenerator(style: .light)`.
|
||||
open private(set) var feedbackGenerator: UIFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
|
||||
|
||||
/// A color set to track when user is not interacting with the slider.
|
||||
/// Default value of this property is `secondarySystemFill`.
|
||||
open var defaultTrackColor: UIColor = .secondarySystemFill {
|
||||
didSet {
|
||||
updateColors()
|
||||
}
|
||||
}
|
||||
/// A color set to progress when user is not interacting with the slider.
|
||||
/// Default value of this property is `.systemFill`.
|
||||
open var defaultProgressColor: UIColor = .systemFill {
|
||||
didSet {
|
||||
updateColors()
|
||||
}
|
||||
}
|
||||
/// A color set to track when user is interacting with the slider.
|
||||
/// Assigning `nil` to this property disables color changes in interactive state.
|
||||
/// Default value of this property is `nil`.
|
||||
open var enlargedTrackColor: UIColor? {
|
||||
didSet {
|
||||
updateColors()
|
||||
}
|
||||
}
|
||||
/// A color set to progress when user is interacting with the slider.
|
||||
/// Assigning `nil` to this property disables color changes in interactive state.
|
||||
/// Default value of this property is `nil`.
|
||||
open var enlargedProgressColor: UIColor? {
|
||||
didSet {
|
||||
updateColors()
|
||||
}
|
||||
}
|
||||
|
||||
/// The slider's current value. Ranges between `0.0` and `1.0`.
|
||||
public var value: Float {
|
||||
get {
|
||||
return Float(progressView.bounds.width / trackView.bounds.width)
|
||||
}
|
||||
set {
|
||||
let normalizedValue = max(0.0001, min(1, newValue))
|
||||
let cgFloatValue = CGFloat(normalizedValue)
|
||||
progressConstraint = progressConstraint.constraintWithMultiplier(cgFloatValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// A publisher that emits progress updates when user interacts with the slider.
|
||||
/// A Combine alternative to adding action for `UIControl.Event.valueChanged`.
|
||||
public private(set) lazy var valuePublisher: SliderControlValuePublisher = .init(control: self)
|
||||
|
||||
public override var intrinsicContentSize: CGSize {
|
||||
return CGSize(width: UIView.noIntrinsicMetric, height: Self.intrinsicHeight)
|
||||
}
|
||||
|
||||
private static let intrinsicHeight: CGFloat = 24
|
||||
private static let defaultTrackHeight: CGFloat = 7
|
||||
private static let enlargedTrackHeight: CGFloat = 12
|
||||
|
||||
private let trackView: UIView = .init()
|
||||
private let progressView: UIView = .init()
|
||||
|
||||
private var heightConstraint: NSLayoutConstraint = .init()
|
||||
private var progressConstraint: NSLayoutConstraint = .init()
|
||||
private var hasPreviousSessionChangedProgress: Bool = false
|
||||
|
||||
override public init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
setup()
|
||||
}
|
||||
|
||||
required public init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
setup()
|
||||
}
|
||||
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
trackView.layer.cornerRadius = trackView.bounds.height / 2
|
||||
}
|
||||
|
||||
override public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
|
||||
enlargeTrack()
|
||||
}
|
||||
|
||||
public override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesMoved(touches, with: event)
|
||||
|
||||
if let touch = touches.first {
|
||||
let previousLocation = touch.previousLocation(in: self)
|
||||
let location = touch.location(in: self)
|
||||
let translationX = location.x - previousLocation.x
|
||||
let newWidth = effectiveUserInterfaceLayoutDirection == .leftToRight
|
||||
? progressView.bounds.width + translationX
|
||||
: progressView.bounds.width - translationX
|
||||
|
||||
let progress = progressView.bounds.width / trackView.bounds.width
|
||||
let newProgress = max(0, min(1, newWidth / trackView.bounds.width))
|
||||
|
||||
if newProgress != progress {
|
||||
switch newProgress {
|
||||
case 0:
|
||||
provideHapticFeedbackForMinimumValue()
|
||||
case 1:
|
||||
provideHapticFeedbackForMaximumValue()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let normalizedConstraintMultiplier = max(0.0001, min(1, newProgress))
|
||||
progressConstraint = progressConstraint.constraintWithMultiplier(normalizedConstraintMultiplier)
|
||||
|
||||
hasPreviousSessionChangedProgress = true
|
||||
if isContinuous {
|
||||
sendActions(for: .valueChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override public func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesEnded(touches, with: event)
|
||||
|
||||
reduceTrack()
|
||||
|
||||
if hasPreviousSessionChangedProgress {
|
||||
hasPreviousSessionChangedProgress = false
|
||||
sendActions(for: .valueChanged)
|
||||
}
|
||||
}
|
||||
|
||||
public override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesCancelled(touches, with: event)
|
||||
|
||||
reduceTrack()
|
||||
|
||||
if hasPreviousSessionChangedProgress {
|
||||
hasPreviousSessionChangedProgress = false
|
||||
sendActions(for: .valueChanged)
|
||||
}
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
isMultipleTouchEnabled = false
|
||||
backgroundColor = .clear
|
||||
|
||||
trackView.clipsToBounds = true
|
||||
trackView.backgroundColor = defaultTrackColor
|
||||
trackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(trackView)
|
||||
|
||||
addLayoutGuide(trackLayoutGuide)
|
||||
|
||||
progressView.clipsToBounds = true
|
||||
progressView.backgroundColor = defaultProgressColor
|
||||
progressView.translatesAutoresizingMaskIntoConstraints = false
|
||||
trackView.addSubview(progressView)
|
||||
|
||||
heightConstraint = trackView.heightAnchor.constraint(equalToConstant: Self.defaultTrackHeight)
|
||||
progressConstraint = progressView.widthAnchor.constraint(equalTo: trackView.widthAnchor, multiplier: 0.5)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
heightConstraint,
|
||||
trackView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
trackView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
trackView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
|
||||
trackLayoutGuide.topAnchor.constraint(equalTo: trackView.topAnchor),
|
||||
trackLayoutGuide.leadingAnchor.constraint(equalTo: trackView.leadingAnchor),
|
||||
trackView.bottomAnchor.constraint(equalTo: trackLayoutGuide.bottomAnchor),
|
||||
trackView.trailingAnchor.constraint(equalTo: trackLayoutGuide.trailingAnchor),
|
||||
|
||||
progressConstraint,
|
||||
progressView.topAnchor.constraint(equalTo: trackView.topAnchor),
|
||||
progressView.leadingAnchor.constraint(equalTo: trackView.leadingAnchor),
|
||||
progressView.bottomAnchor.constraint(equalTo: trackView.bottomAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
/// The control calls this method upon reaching minimum value. Override this
|
||||
/// implementation to customize haptic feedback. Your implementation should
|
||||
/// not call `super.provideHapticFeedbackForMinimumValue()` at any point.
|
||||
/// You should not call this method directly.
|
||||
open func provideHapticFeedbackForMinimumValue() {
|
||||
guard providesHapticFeedback else { return }
|
||||
|
||||
(feedbackGenerator as? UIImpactFeedbackGenerator)?.impactOccurred(intensity: 0.75)
|
||||
}
|
||||
|
||||
/// The control calls this method upon reaching maximum value. Override this
|
||||
/// implementation to customize haptic feedback. Your implementation should
|
||||
/// not call `super.provideHapticFeedbackForMaximumValue()` at any point.
|
||||
/// You should not call this method directly.
|
||||
open func provideHapticFeedbackForMaximumValue() {
|
||||
guard providesHapticFeedback else { return }
|
||||
|
||||
(feedbackGenerator as? UIImpactFeedbackGenerator)?.impactOccurred(intensity: 1)
|
||||
}
|
||||
|
||||
private func enlargeTrack() {
|
||||
heightConstraint.constant = Self.enlargedTrackHeight
|
||||
setNeedsLayout()
|
||||
|
||||
UIView.animate(
|
||||
withDuration: 0.25,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 1,
|
||||
initialSpringVelocity: 0,
|
||||
options: [.curveEaseIn, .allowAnimatedContent, .allowUserInteraction]
|
||||
) { [unowned self] in
|
||||
enlargedTrackColor.map { trackView.backgroundColor = $0 }
|
||||
enlargedProgressColor.map { progressView.backgroundColor = $0 }
|
||||
|
||||
layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func reduceTrack() {
|
||||
heightConstraint.constant = 7
|
||||
setNeedsLayout()
|
||||
|
||||
UIView.animate(
|
||||
withDuration: 0.5,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 1,
|
||||
initialSpringVelocity: 1,
|
||||
options: [.curveEaseOut, .allowAnimatedContent, .allowUserInteraction]
|
||||
) { [unowned self] in
|
||||
trackView.backgroundColor = defaultTrackColor
|
||||
progressView.backgroundColor = defaultProgressColor
|
||||
|
||||
layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateColors() {
|
||||
if heightConstraint.constant == Self.defaultTrackHeight {
|
||||
trackView.backgroundColor = defaultTrackColor
|
||||
progressView.backgroundColor = defaultProgressColor
|
||||
} else {
|
||||
enlargedTrackColor.map { trackView.backgroundColor = $0 }
|
||||
enlargedProgressColor.map { progressView.backgroundColor = $0 }
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in new issue