CompactSlider.swift (5952B)
1 import SwiftUI 2 import UIKit 3 4 /// A compact `UISlider` wrapped for SwiftUI. SwiftUI's `Slider` renders a fixed 5 /// ~28pt system thumb that ignores `.controlSize`; this draws a small white 6 /// rounded-rectangle thumb (with a hairline border and soft shadow for depth) 7 /// over a thin track whose filled portion takes the player's colour. Reports 8 /// integer positions (one per scrub step). 9 struct CompactSlider: UIViewRepresentable { 10 @Binding var value: Int 11 let range: ClosedRange<Int> 12 var trackHeight: CGFloat = 1 13 var thumbSize = CGSize(width: 14, height: 14) 14 var thumbCornerRadius: CGFloat = 4 15 /// Filled (minimum) track colour — pass the player's colour. Opaque. 16 var fillColor: UIColor = .systemGray 17 /// Unfilled (maximum) track colour. Neutral so it reads behind any fill. 18 var trackColor: UIColor = .systemGray4 19 /// Called when the *user* moves the thumb. Programmatic `setValue` (e.g. 20 /// autoplay advancing the position) does not fire `.valueChanged`, so this 21 /// is exclusively a manual scrub — the cue to cancel autoplay. 22 var onUserScrub: (() -> Void)? = nil 23 24 func makeCoordinator() -> Coordinator { Coordinator(self) } 25 26 func makeUIView(context: Context) -> UISlider { 27 let slider = UISlider() 28 // A custom track image controls the track's thickness (the default is 29 // ~3–4pt). Rendered as a template so the tint colours below drive its 30 // colour (and adapt to light/dark). 31 let track = Self.trackImage(height: trackHeight) 32 slider.setMinimumTrackImage(track, for: .normal) 33 slider.setMaximumTrackImage(track, for: .normal) 34 slider.minimumTrackTintColor = fillColor 35 slider.maximumTrackTintColor = trackColor 36 let thumb = Self.thumbImage(size: thumbSize, cornerRadius: thumbCornerRadius) 37 slider.setThumbImage(thumb, for: .normal) 38 slider.setThumbImage(thumb, for: .highlighted) 39 slider.accessibilityLabel = "Replay position" 40 slider.addTarget( 41 context.coordinator, 42 action: #selector(Coordinator.valueChanged(_:)), 43 for: .valueChanged 44 ) 45 // Don't let the small thumb dictate width; fill the row instead. 46 slider.setContentHuggingPriority(.defaultLow, for: .horizontal) 47 slider.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 48 return slider 49 } 50 51 func updateUIView(_ slider: UISlider, context: Context) { 52 context.coordinator.parent = self 53 slider.minimumValue = Float(range.lowerBound) 54 slider.maximumValue = Float(range.upperBound) 55 // Keep the fill in sync if the player's colour changes. 56 slider.minimumTrackTintColor = fillColor 57 // Don't fight the user's finger: only sync the thumb to `value` when no 58 // drag is in progress. 59 if !slider.isTracking { 60 slider.setValue(Float(value), animated: false) 61 } 62 } 63 64 /// A white rounded-rectangle thumb with a hairline border and a soft drop 65 /// shadow. The image is padded so the shadow isn't clipped; `UISlider` 66 /// centres it on the track, so the padding reads as a slightly larger touch 67 /// target. 68 private static func thumbImage(size: CGSize, cornerRadius: CGFloat) -> UIImage { 69 let shadowBlur: CGFloat = 1.5 70 let shadowOffset = CGSize(width: 0, height: 0.5) 71 let pad = shadowBlur + max(abs(shadowOffset.width), abs(shadowOffset.height)) + 1 72 let imageSize = CGSize(width: size.width + pad * 2, height: size.height + pad * 2) 73 return UIGraphicsImageRenderer(size: imageSize).image { ctx in 74 let cg = ctx.cgContext 75 let rect = CGRect(x: pad, y: pad, width: size.width, height: size.height) 76 let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius) 77 78 cg.saveGState() 79 cg.setShadow( 80 offset: shadowOffset, 81 blur: shadowBlur, 82 color: UIColor.black.withAlphaComponent(0.3).cgColor 83 ) 84 UIColor.white.setFill() 85 path.fill() 86 cg.restoreGState() 87 88 // Hairline border so a white thumb stays defined on a light panel. 89 let borderWidth: CGFloat = 0.5 90 let inset = borderWidth / 2 91 let borderPath = UIBezierPath( 92 roundedRect: rect.insetBy(dx: inset, dy: inset), 93 cornerRadius: max(0, cornerRadius - inset) 94 ) 95 UIColor.black.withAlphaComponent(0.12).setStroke() 96 borderPath.lineWidth = borderWidth 97 borderPath.stroke() 98 } 99 } 100 101 /// A `height`-tall capsule, resizable along its width, as an 102 /// `.alwaysTemplate` image so the slider's track tint colours drive its 103 /// colour. The image's height becomes the rendered track thickness. 104 private static func trackImage(height: CGFloat) -> UIImage { 105 let radius = height / 2 106 let size = CGSize(width: height, height: height) 107 let image = UIGraphicsImageRenderer(size: size).image { _ in 108 UIColor.black.setFill() // colour irrelevant: tinted as a template 109 UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: radius).fill() 110 } 111 let inset = max(0, radius - 0.5) 112 return image 113 .resizableImage( 114 withCapInsets: UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset), 115 resizingMode: .stretch 116 ) 117 .withRenderingMode(.alwaysTemplate) 118 } 119 120 @MainActor 121 final class Coordinator: NSObject { 122 var parent: CompactSlider 123 init(_ parent: CompactSlider) { self.parent = parent } 124 125 @objc func valueChanged(_ slider: UISlider) { 126 parent.onUserScrub?() 127 let rounded = Int(slider.value.rounded()) 128 if rounded != parent.value { parent.value = rounded } 129 } 130 } 131 }