Skip to content

Double Tap Seek Overlay

Double tapping the left or right half of a video skips back or forward with a ripple and a seconds label.

Double Tap Seek Overlay, playing
  • Media
  • iOS 18.0+
  • intermediate
Download .swift
Origin
Original work
Found by
double tap · seek · video · skip · ripple · gesture · player · doble toque · avanzar · retroceder · vídeo · ondulación · gesto · reproductor

Double tap the left or right half of a video to skip back or forward.

onSeek receives the seconds to move, negative when going back. Put buttons in controls, which sits above the seek gesture: a double tap on one runs it and does not seek, while one left inside video would do both. VoiceOver users get the same skips as named actions. With Reduce Motion on, the ripple is left out.

struct DoubleTapSeekOverlayDemo: View {
private let duration = 180.0
@State private var position = 60.0
@State private var seekRequest: DoubleTapSeekOverlayRequest?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 18) {
Text("Double tap either side")
.font(.headline)
.foregroundStyle(.white.opacity(0.8))
DoubleTapSeekOverlay(onSeek: { position = min(max(position + $0, 0), duration) }, programmaticSeek: seekRequest) {
videoPlaceholder
}
.aspectRatio(16 / 9, contentMode: .fit)
.clipShape(.rect(cornerRadius: 16))
ProgressView(value: position, total: duration)
.tint(.red)
.accessibilityLabel("Playback position")
Text(Duration.seconds(position).formatted(.time(pattern: .minuteSecond)))
.font(.title3.monospacedDigit())
.foregroundStyle(.white)
.accessibilityHidden(true)
}
.padding(.horizontal, 16)
}
.task(id: reduceMotion) {
guard !reduceMotion else { return }
await playSeeks()
}
}
// Only the demo plays by itself, because a real double tap needs a finger.
private func playSeeks() async {
let steps: [(side: DoubleTapSeekOverlaySide, delay: Double)] = [(.forward, 0.7), (.forward, 0.45), (.backward, 1.5), (.backward, 0.45)]
while !Task.isCancelled {
for step in steps {
try? await Task.sleep(for: .seconds(step.delay))
guard !Task.isCancelled else { return }
seekRequest = DoubleTapSeekOverlayRequest(side: step.side)
}
try? await Task.sleep(for: .seconds(1.2))
}
}
private var videoPlaceholder: some View {
TimelineView(.animation(minimumInterval: 1.0 / 20.0, paused: reduceMotion)) { context in
let time = reduceMotion ? 0 : context.date.timeIntervalSinceReferenceDate
LinearGradient(
colors: [.indigo, .purple, .orange],
startPoint: UnitPoint(x: 0.5 + sin(time * 0.7) * 0.5, y: 0),
endPoint: UnitPoint(x: 0.5 - sin(time * 0.7) * 0.5, y: 1)
)
}
.overlay {
Image(systemName: "film")
.font(.system(.largeTitle, weight: .light))
.foregroundStyle(.white.opacity(0.45))
}
.accessibilityHidden(true)
}
}
ParameterTypeDefaultDescription
intervalDouble10.0
onSeek(Double) -> VoidRequired
programmaticSeekDoubleTapSeekOverlayRequest?nilSkips without a touch, for demos. Each new value is one skip.
video() -> VideoRequired
controls() -> ControlsRequired

init(interval:onSeek:programmaticSeek:video:)

ParameterTypeDefault
intervalDouble10.0
onSeek@escaping (Double) -> VoidRequired
programmaticSeekDoubleTapSeekOverlayRequest?nil
video@escaping () -> VideoRequired

Copy this code into your project, or download the file.

DoubleTapSeekOverlay.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
enum DoubleTapSeekOverlaySide: Sendable {
case backward, forward
}
struct DoubleTapSeekOverlayRequest: Equatable {
let id = UUID()
let side: DoubleTapSeekOverlaySide
}
/// Adds up repeated double taps on one side, like "+10 s" then "+20 s".
struct DoubleTapSeekOverlayTally: Equatable {
private(set) var side: DoubleTapSeekOverlaySide?
private(set) var seconds = 0.0
static func side(forX x: CGFloat, width: CGFloat) -> DoubleTapSeekOverlaySide {
x < width / 2 ? .backward : .forward
}
mutating func record(_ newSide: DoubleTapSeekOverlaySide, interval: Double) {
seconds = side == newSide ? seconds + interval : interval
side = newSide
}
mutating func reset() {
self = DoubleTapSeekOverlayTally()
}
}
/// Double tap the left or right half of a video to skip back or forward.
///
/// `onSeek` receives the seconds to move, negative when going back. Put buttons in `controls`,
/// which sits above the seek gesture: a double tap on one runs it and does not seek, while one
/// left inside `video` would do both. VoiceOver users get the same skips as named actions.
/// With Reduce Motion on, the ripple is left out.
struct DoubleTapSeekOverlay<Video: View, Controls: View>: View {
var interval = 10.0
let onSeek: (Double) -> Void
/// Skips without a touch, for demos. Each new value is one skip.
var programmaticSeek: DoubleTapSeekOverlayRequest? = nil
@ViewBuilder var video: () -> Video
@ViewBuilder var controls: () -> Controls
@State private var tally = DoubleTapSeekOverlayTally()
@State private var tapCount = 0
@State private var tapLocation = CGPoint.zero
@State private var videoSize = CGSize.zero
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.layoutDirection) private var layoutDirection
private var animation: Animation? { reduceMotion ? nil : .easeOut(duration: 0.2) }
private var intervalText: String { interval.formatted() }
var body: some View {
video()
.contentShape(.rect)
.onGeometryChange(for: CGSize.self) { $0.size } action: { videoSize = $0 }
// Simultaneous, so a tap handler inside the video still runs.
.simultaneousGesture(
SpatialTapGesture(count: 2).onEnded { value in
tapLocation = value.location
seek(DoubleTapSeekOverlayTally.side(forX: value.location.x, width: videoSize.width))
}
)
.overlay {
GeometryReader { proxy in
ZStack {
Color.clear
.allowsHitTesting(false)
.accessibilityElement()
.accessibilityLabel("Seek area")
.accessibilityAction(named: "Skip back \(intervalText) seconds") { seek(.backward) }
.accessibilityAction(named: "Skip forward \(intervalText) seconds") { seek(.forward) }
if let side = tally.side {
feedback(side, in: proxy.size).transition(.opacity)
}
}
}
}
.overlay { controls() }
.onChange(of: programmaticSeek) { _, request in
guard let request else { return }
tapLocation = CGPoint(x: videoSize.width * (request.side == .forward ? 0.75 : 0.25), y: videoSize.height / 2)
seek(request.side)
}
.task(id: tapCount) {
guard tapCount > 0 else { return }
try? await Task.sleep(for: .seconds(0.9))
guard !Task.isCancelled else { return }
withAnimation(animation) { tally.reset() }
}
}
private func feedback(_ side: DoubleTapSeekOverlaySide, in size: CGSize) -> some View {
let halfWidth = size.width / 2
let isForward = side == .forward
let rippleCenter = CGPoint(x: isForward ? tapLocation.x - halfWidth : tapLocation.x, y: tapLocation.y)
return ZStack {
Color.black.opacity(0.22)
if !reduceMotion {
DoubleTapSeekOverlayRipple(center: rippleCenter, diameter: max(size.width, size.height))
.id(tapCount)
}
VStack(spacing: 6) {
Image(systemName: isForward ? "forward.fill" : "backward.fill").font(.title2)
Text("\(isForward ? "+" : "-")\(tally.seconds.formatted()) s")
.font(.headline.monospacedDigit())
}
.foregroundStyle(.white)
.environment(\.layoutDirection, layoutDirection)
}
.frame(width: max(halfWidth, 0), height: max(size.height, 0))
.clipped()
.frame(maxWidth: .infinity, alignment: isForward ? .trailing : .leading)
// Playback keeps its sides in right-to-left layouts: back stays on the left, where the taps are counted.
.environment(\.layoutDirection, .leftToRight)
.allowsHitTesting(false)
.accessibilityHidden(true)
}
private func seek(_ side: DoubleTapSeekOverlaySide) {
guard interval > 0 else { return }
withAnimation(animation) { tally.record(side, interval: interval) }
tapCount &+= 1
onSeek(side == .backward ? -interval : interval)
}
}
extension DoubleTapSeekOverlay where Controls == EmptyView {
init(interval: Double = 10.0, onSeek: @escaping (Double) -> Void, programmaticSeek: DoubleTapSeekOverlayRequest? = nil, @ViewBuilder video: @escaping () -> Video) {
self.init(interval: interval, onSeek: onSeek, programmaticSeek: programmaticSeek, video: video, controls: { EmptyView() })
}
}
private struct DoubleTapSeekOverlayRipple: View {
let center: CGPoint
let diameter: CGFloat
@State private var isExpanded = false
var body: some View {
Circle()
.fill(.white.opacity(0.35))
.frame(width: diameter, height: diameter)
.scaleEffect(isExpanded ? 1 : 0.1)
.opacity(isExpanded ? 0 : 1)
.position(center)
.onAppear {
withAnimation(.easeOut(duration: 0.55)) { isExpanded = true }
}
}
}
// MARK: - Demo
struct DoubleTapSeekOverlayDemo: View {
private let duration = 180.0
@State private var position = 60.0
@State private var seekRequest: DoubleTapSeekOverlayRequest?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 18) {
Text("Double tap either side")
.font(.headline)
.foregroundStyle(.white.opacity(0.8))
DoubleTapSeekOverlay(onSeek: { position = min(max(position + $0, 0), duration) }, programmaticSeek: seekRequest) {
videoPlaceholder
}
.aspectRatio(16 / 9, contentMode: .fit)
.clipShape(.rect(cornerRadius: 16))
ProgressView(value: position, total: duration)
.tint(.red)
.accessibilityLabel("Playback position")
Text(Duration.seconds(position).formatted(.time(pattern: .minuteSecond)))
.font(.title3.monospacedDigit())
.foregroundStyle(.white)
.accessibilityHidden(true)
}
.padding(.horizontal, 16)
}
.task(id: reduceMotion) {
guard !reduceMotion else { return }
await playSeeks()
}
}
// Only the demo plays by itself, because a real double tap needs a finger.
private func playSeeks() async {
let steps: [(side: DoubleTapSeekOverlaySide, delay: Double)] = [(.forward, 0.7), (.forward, 0.45), (.backward, 1.5), (.backward, 0.45)]
while !Task.isCancelled {
for step in steps {
try? await Task.sleep(for: .seconds(step.delay))
guard !Task.isCancelled else { return }
seekRequest = DoubleTapSeekOverlayRequest(side: step.side)
}
try? await Task.sleep(for: .seconds(1.2))
}
}
private var videoPlaceholder: some View {
TimelineView(.animation(minimumInterval: 1.0 / 20.0, paused: reduceMotion)) { context in
let time = reduceMotion ? 0 : context.date.timeIntervalSinceReferenceDate
LinearGradient(
colors: [.indigo, .purple, .orange],
startPoint: UnitPoint(x: 0.5 + sin(time * 0.7) * 0.5, y: 0),
endPoint: UnitPoint(x: 0.5 - sin(time * 0.7) * 0.5, y: 1)
)
}
.overlay {
Image(systemName: "film")
.font(.system(.largeTitle, weight: .light))
.foregroundStyle(.white.opacity(0.45))
}
.accessibilityHidden(true)
}
}
#Preview {
DoubleTapSeekOverlayDemo()
}