crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }