```html SwiftUI: How to Implement Video Player (iOS 17+, 2026)

How to implement a video player in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: VideoPlayer, AVPlayer Updated: May 11, 2026
TL;DR

Import AVKit, create an AVPlayer with your video URL, then pass it to SwiftUI's VideoPlayer view — that's all you need for a fully native playback experience with scrubbing, volume, and fullscreen built in.

import SwiftUI
import AVKit

struct QuickVideoView: View {
    private let player = AVPlayer(
        url: URL(string: "https://example.com/clip.mp4")!
    )

    var body: some View {
        VideoPlayer(player: player)
            .onAppear { player.play() }
            .onDisappear { player.pause() }
            .aspectRatio(16/9, contentMode: .fit)
    }
}

Full implementation

The example below wraps a remote MP4 in a reusable VideoPlayerView. It stores the AVPlayer as a @State property so SwiftUI owns the object lifecycle, layers a custom title overlay using the VideoPlayer content closure, and loops the video automatically via AVPlayerLooper. A secondary view demonstrates swapping between multiple clips without recreating the player.

import SwiftUI
import AVKit
import AVFoundation

// MARK: - Model

struct VideoItem: Identifiable {
    let id = UUID()
    let title: String
    let url: URL
}

// MARK: - Main player view

struct VideoPlayerView: View {
    let item: VideoItem

    // Keep the player alive with @State so SwiftUI manages its lifecycle.
    @State private var player: AVPlayer

    init(item: VideoItem) {
        self.item = item
        _player = State(initialValue: AVPlayer(url: item.url))
    }

    var body: some View {
        VideoPlayer(player: player) {
            // Custom overlay rendered on top of the native controls.
            VStack {
                HStack {
                    Text(item.title)
                        .font(.headline)
                        .foregroundStyle(.white)
                        .padding(8)
                        .background(.black.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
                        .accessibilityLabel("Now playing: \(item.title)")
                    Spacer()
                }
                .padding()
                Spacer()
            }
        }
        .aspectRatio(16 / 9, contentMode: .fit)
        .clipShape(RoundedRectangle(cornerRadius: 12))
        .onAppear {
            player.play()
        }
        .onDisappear {
            player.pause()
        }
        // Replace the player item when the URL changes.
        .onChange(of: item.url) { _, newURL in
            player.replaceCurrentItem(with: AVPlayerItem(url: newURL))
            player.play()
        }
        // Loop by observing end-of-playback notification.
        .onReceive(
            NotificationCenter.default.publisher(
                for: AVPlayerItem.didPlayToEndTimeNotification
            )
        ) { _ in
            player.seek(to: .zero)
            player.play()
        }
    }
}

// MARK: - Playlist picker

struct VideoPlaylistView: View {
    private let items: [VideoItem] = [
        VideoItem(
            title: "Ocean Waves",
            url: URL(string: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4")!
        ),
        VideoItem(
            title: "Mountain Trail",
            url: URL(string: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4")!
        ),
    ]

    @State private var selected: VideoItem

    init() {
        _selected = State(initialValue: VideoPlaylistView.placeholderItems()[0])
    }

    private static func placeholderItems() -> [VideoItem] {
        [
            VideoItem(
                title: "Ocean Waves",
                url: URL(string: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4")!
            ),
            VideoItem(
                title: "Mountain Trail",
                url: URL(string: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4")!
            ),
        ]
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 16) {
                VideoPlayerView(item: selected)
                    .padding(.horizontal)

                ScrollView(.horizontal, showsIndicators: false) {
                    HStack(spacing: 12) {
                        ForEach(Self.placeholderItems()) { item in
                            Button(item.title) {
                                selected = item
                            }
                            .buttonStyle(.bordered)
                            .tint(selected.id == item.id ? .blue : .gray)
                        }
                    }
                    .padding(.horizontal)
                }
            }
            .navigationTitle("Videos")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

// MARK: - Preview

#Preview {
    VideoPlaylistView()
}

How it works

  1. @State private var player: AVPlayer — Storing the player as @State (instead of a plain property) ensures SwiftUI preserves the same object across re-renders. Creating it in init via State(initialValue:) avoids allocating a new player every time the view is evaluated.
  2. VideoPlayer(player:) { ... } — The trailing content closure accepts any SwiftUI view and composites it over the native playback layer. The system's scrubber, play/pause button, volume slider, and fullscreen button are still present underneath your overlay.
  3. .onAppear / .onDisappear — These modifiers start and pause playback at the right moment in the view lifecycle, preventing audio from continuing when the view leaves the hierarchy (e.g., navigating back in a NavigationStack).
  4. .onChange(of: item.url) — Uses the two-parameter iOS 17 onChange signature (old value, new value) to swap the AVPlayerItem without tearing down the whole view — the player's internal buffers and audio session stay intact.
  5. AVPlayerItem.didPlayToEndTimeNotification — Subscribing to this NotificationCenter publisher is the idiomatic way to implement looping. Seeking to .zero and calling play() restarts seamlessly without allocating a new AVPlayerLooper (which requires an AVQueuePlayer).

Variants

Playing a local bundled video file

struct LocalVideoView: View {
    // Add "demo.mp4" to your Xcode target's bundle resources.
    private let player: AVPlayer = {
        guard let url = Bundle.main.url(
            forResource: "demo",
            withExtension: "mp4"
        ) else { fatalError("demo.mp4 not found in bundle") }
        return AVPlayer(url: url)
    }()

    var body: some View {
        VideoPlayer(player: player)
            .aspectRatio(16 / 9, contentMode: .fit)
            .onAppear { player.play() }
    }
}

#Preview { LocalVideoView() }

Muted auto-playing background video (hero banner)

For a looping silent background clip — common in onboarding or marketing screens — set player.isMuted = true before calling play(), and hide the native controls by sizing the VideoPlayer to fill a ZStack behind your content. Because native controls are touch-driven, they'll simply never appear in a non-interactive context. Combine with .allowsHitTesting(false) on the player layer to ensure taps fall through to your UI.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a video player in SwiftUI for iOS 17+.
Use VideoPlayer and AVPlayer from AVKit.
Support both local bundle files and remote HTTPS URLs.
Loop the video automatically on end.
Make it accessible (VoiceOver labels on overlay controls).
Add a #Preview with realistic sample data.

Drop this prompt into Soarias during the Build phase — it slots directly into a feature branch so the generated VideoPlayerView lands alongside your other screens without touching unrelated code.

Related

FAQ

Does this work on iOS 16?

VideoPlayer itself is available from iOS 14, and AVPlayer has been around since iOS 4. The only iOS 17-specific piece in this guide is the two-parameter onChange(of:) { old, new in } closure. Swap that for the single-parameter onChange(of:) { _ in } variant and the code compiles and runs on iOS 16 without modification.

How do I observe playback progress for a custom scrubber?

Use AVPlayer.addPeriodicTimeObserver(forInterval:queue:using:) to receive a callback at a fixed interval (e.g., every 0.5 seconds). Store the returned opaque token and pass it to removeTimeObserver(_:) in onDisappear to avoid retain cycles. Wrap the callback in DispatchQueue.main.async before updating @State/@Observable properties, since the queue parameter controls the dispatch thread, not the UI update thread.

What's the UIKit equivalent?

In UIKit you'd use AVPlayerViewController (presented modally or embedded as a child view controller) together with AVPlayerLayer for custom layouts. SwiftUI's VideoPlayer wraps AVPlayerViewController under the hood via UIViewControllerRepresentable, so you get the same native controls and PiP support without any bridging boilerplate.

Last reviewed: 2026-05-11 by the Soarias team.

```