jellyflood/jellypig iOS/Components/Slider/Slider.swift

207 lines
6.7 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) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
struct Slider: View {
enum Behavior {
case thumb
case track
}
@Binding
private var progress: CGFloat
@State
private var isEditing: Bool = false
@State
private var totalWidth: CGFloat = 0
@State
private var dragStartProgress: CGFloat = 0
@State
private var currentTranslationStartLocation: CGPoint = .zero
@State
private var currentTranslation: CGFloat = 0
@State
private var thumbSize: CGSize = .zero
private var sliderBehavior: Behavior
private var trackGesturePadding: EdgeInsets
private var track: () -> any View
private var trackBackground: () -> any View
private var trackMask: () -> any View
private var thumb: () -> any View
private var topContent: () -> any View
private var bottomContent: () -> any View
private var leadingContent: () -> any View
private var trailingContent: () -> any View
private var onEditingChanged: (Bool) -> Void
private var progressAnimation: Animation
private var trackDrag: some Gesture {
DragGesture(coordinateSpace: .global)
.onChanged { value in
if !isEditing {
isEditing = true
onEditingChanged(true)
dragStartProgress = progress
currentTranslationStartLocation = value.location
currentTranslation = 0
}
currentTranslation = currentTranslationStartLocation.x - value.location.x
let newProgress: CGFloat = dragStartProgress - currentTranslation / totalWidth
progress = min(max(0, newProgress), 1)
}
.onEnded { _ in
isEditing = false
onEditingChanged(false)
}
}
var body: some View {
HStack(alignment: .sliderCenterAlignmentGuide, spacing: 0) {
leadingContent()
.eraseToAnyView()
.alignmentGuide(.sliderCenterAlignmentGuide) { context in
context[VerticalAlignment.center]
}
VStack(spacing: 0) {
topContent()
.eraseToAnyView()
ZStack(alignment: .leading) {
ZStack {
trackBackground()
.eraseToAnyView()
track()
.eraseToAnyView()
.mask(alignment: .leading) {
Color.white
.frame(width: progress * totalWidth)
}
}
.mask {
trackMask()
.eraseToAnyView()
}
thumb()
.eraseToAnyView()
.if(sliderBehavior == .thumb) { view in
view.gesture(trackDrag)
}
.onSizeChanged { newSize in
thumbSize = newSize
}
.offset(x: progress * totalWidth - thumbSize.width / 2)
}
.onSizeChanged { size in
totalWidth = size.width
}
.if(sliderBehavior == .track) { view in
view.overlay {
Color.clear
.padding(trackGesturePadding)
.contentShape(Rectangle())
.highPriorityGesture(trackDrag)
}
}
.alignmentGuide(.sliderCenterAlignmentGuide) { context in
context[VerticalAlignment.center]
}
bottomContent()
.eraseToAnyView()
}
trailingContent()
.eraseToAnyView()
.alignmentGuide(.sliderCenterAlignmentGuide) { context in
context[VerticalAlignment.center]
}
}
.animation(progressAnimation, value: progress)
.animation(.linear(duration: 0.2), value: isEditing)
}
}
extension Slider {
init(progress: Binding<CGFloat>) {
self.init(
progress: progress,
sliderBehavior: .track,
trackGesturePadding: .zero,
track: { EmptyView() },
trackBackground: { EmptyView() },
trackMask: { EmptyView() },
thumb: { EmptyView() },
topContent: { EmptyView() },
bottomContent: { EmptyView() },
leadingContent: { EmptyView() },
trailingContent: { EmptyView() },
onEditingChanged: { _ in },
progressAnimation: .linear(duration: 0.05)
)
}
func track(@ViewBuilder _ content: @escaping () -> any View) -> Self {
copy(modifying: \.track, with: content)
}
func trackBackground(@ViewBuilder _ content: @escaping () -> any View) -> Self {
copy(modifying: \.trackBackground, with: content)
}
func trackMask(@ViewBuilder _ content: @escaping () -> any View) -> Self {
copy(modifying: \.trackMask, with: content)
}
func thumb(@ViewBuilder _ content: @escaping () -> any View) -> Self {
copy(modifying: \.thumb, with: content)
}
func topContent(@ViewBuilder _ content: @escaping () -> any View) -> Self {
copy(modifying: \.topContent, with: content)
}
func bottomContent(@ViewBuilder _ content: @escaping () -> any View) -> Self {
copy(modifying: \.bottomContent, with: content)
}
func leadingContent(@ViewBuilder _ content: @escaping () -> any View) -> Self {
copy(modifying: \.leadingContent, with: content)
}
func trailingContent(@ViewBuilder _ content: @escaping () -> any View) -> Self {
copy(modifying: \.trailingContent, with: content)
}
func trackGesturePadding(_ insets: EdgeInsets) -> Self {
copy(modifying: \.trackGesturePadding, with: insets)
}
func onEditingChanged(_ action: @escaping (Bool) -> Void) -> Self {
copy(modifying: \.onEditingChanged, with: action)
}
func gestureBehavior(_ sliderBehavior: Behavior) -> Self {
copy(modifying: \.sliderBehavior, with: sliderBehavior)
}
func progressAnimation(_ animation: Animation) -> Self {
copy(modifying: \.progressAnimation, with: animation)
}
}