Mobile Development 16 min read

Implementing Picture-in-Picture (PiP) on iOS: System Player, Custom Player, Timer and English Listening App

This article explains how to enable and use iOS Picture-in-Picture for both system and custom video players, create a timer‑based PiP view, and integrate PiP into an English listening app, while comparing two implementation approaches and offering performance tips.

Sohu Tech Products
Sohu Tech Products
Sohu Tech Products
Implementing Picture-in-Picture (PiP) on iOS: System Player, Custom Player, Timer and English Listening App

Background

The author discovered a PiP implementation that shows hour‑minute‑second timers and wanted to explore how the Daily English Listening app uses PiP to display subtitles, as well as how to create a PiP timer for precise countdowns.

Implementation Overview

The process is divided into five steps:

Identify required capabilities and methods for PiP.

Implement PiP using the system player.

Implement PiP with a custom player.

Implement a PiP timer.

Show how Daily English Listening uses PiP for subtitles.

Enabling PiP in the App

Add BackgroundModes in the Xcode project and enable Audio, AirPlay, and Picture in Picture . Then configure AVAudioSession in AppDelegate.swift :

import AVFoundation

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    do {
        try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
    } catch {
        print(error)
    }
    return true
}

System Player PiP

Use AVPlayerViewController from AVKit :

import AVKit

fileprivate func playerResource() -> AVQueuePlayer? {
    guard let videoURL = Bundle.main.url(forResource: "suancaidegang", withExtension: "mp4") else { return nil }
    let item = AVPlayerItem(url: videoURL)
    let player = AVQueuePlayer(playerItem: item)
    player.actionAtItemEnd = .pause
    return player
}

@IBAction func systemPlayerAction(_ sender: Any) {
    guard let player = playerResource() else { return }
    let avPlayerVC = AVPlayerViewController()
    avPlayerVC.player = player
    present(avPlayerVC, animated: true) { player.play() }
}

Set AVPlayerViewControllerDelegate to control whether the original view dismisses when PiP starts:

extension ViewController: AVPlayerViewControllerDelegate {
    func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool {
        return false // keep the original view visible while PiP is active
    }
}

When PiP stops, restore the UI using playerViewController(_:restoreUserInterfaceForPictureInPictureStopWithCompletionHandler:) .

Custom Player PiP

Define a custom player view controller that holds an AVPictureInPictureController and a button to start PiP:

protocol CustomPlayerVCDelegate: AnyObject {
    func playerViewController(_ playerViewController: MWCustomPlayerVC, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void)
}

private var activeCustomPlayerVCs = Set
()

class MWCustomPlayerVC: UIViewController {
    private var pictureInPictureVC: AVPictureInPictureController?
    weak var delegate: CustomPlayerVCDelegate?
    var autoDismissAtPip: Bool = false
    var enterPipBtn: CustomPlayerCircularButtonView?

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .black
        setupPictureInPictureVC()
        setupEnterPipBtn()
    }

    private func setupPictureInPictureVC() {
        guard let playerLayer = playerLayer else { return }
        pictureInPictureVC = AVPictureInPictureController(playerLayer: playerLayer)
        pictureInPictureVC?.delegate = self
    }

    private func setupEnterPipBtn() {
        enterPipBtn = CustomPlayerCircularButtonView(symbolName: "pip.enter", height: 50.0)
        enterPipBtn?.addTarget(self, action: #selector(handleEnterPipAction), for: [.primaryActionTriggered, .touchUpInside])
        view.addSubview(enterPipBtn!)
        // layout with SnapKit omitted for brevity
    }

    @objc private func handleEnterPipAction() {
        pictureInPictureVC?.startPictureInPicture()
    }
}

extension MWCustomPlayerVC: AVPictureInPictureControllerDelegate {
    func pictureInPictureControllerWillStartPictureInPicture(_ controller: AVPictureInPictureController) {
        activeCustomPlayerVCs.insert(self)
        enterPipBtn?.isHidden = true
    }
    func pictureInPictureControllerDidStartPictureInPicture(_ controller: AVPictureInPictureController) {
        if autoDismissAtPip { dismiss(animated: true) }
    }
    func pictureInPictureController(_ controller: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
        activeCustomPlayerVCs.remove(self)
        enterPipBtn?.isHidden = false
    }
    func pictureInPictureController(_ controller: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
        delegate?.playerViewController(self, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler: completionHandler)
    }
}

Use the custom player from a regular view controller and implement the delegate to restore the UI after PiP stops.

PiP Timer Using UIPiPView

Leverage the third‑party UIPiPView to display any view in PiP. The timer view updates every 0.1/60 seconds:

import UIKit
import UIPiPView
import SnapKit

class MWFullTimerVC: MWBaseVC {
    private let pipView = UIPiPView()
    private let timeLabel = UILabel()
    private let dateFormatStr = "yyyy-MM-dd HH:mm:ss"
    private var timer: Timer?

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .black
        setupPipView()
        setupTimeLabel()
        createDisplayLink()
    }

    private func setupPipView() {
        let width = UIScreen.main.bounds.width
        pipView.frame = CGRect(x: 10.0, y: 0, width: width - 20.0, height: 50.0)
        view.addSubview(pipView)
        // SnapKit constraints omitted for brevity
    }

    private func setupTimeLabel() {
        timeLabel.font = .boldSystemFont(ofSize: 16.0)
        timeLabel.textColor = .white
        timeLabel.backgroundColor = .orange
        timeLabel.textAlignment = .center
        pipView.addSubview(timeLabel)
        // SnapKit constraints omitted for brevity
    }

    private func createDisplayLink() {
        timer = Timer(timeInterval: 0.1/60, repeats: true) { [weak self] _ in
            self?.refresh()
        }
        RunLoop.current.add(timer!, forMode: .common)
        timer?.fire()
    }

    func reloadTime() {
        let date = Date()
        let formatter = DateFormatter()
        formatter.dateFormat = dateFormatStr
        timeLabel.text = formatter.string(from: date)
    }

    func refresh() { reloadTime() }

    override func handleEnterPipAction() {
        super.handleEnterPipAction()
        if pipView.isPictureInPictureActive() {
            pipView.stopPictureInPicture()
        } else {
            pipView.startPictureInPicture(withRefreshInterval: 0.1/60.0)
        }
    }
}

This approach renders any custom view (e.g., a timer label) inside PiP without needing a video file.

Daily English Listening PiP

The app adds a subtitle view to the topmost window when PiP starts, updating the text every two seconds:

func setupTextPlayerView(on targetView: UIView) {
    targetView.addSubview(textPlayView)
    textPlayView.text = text1
    // constraints omitted
}

private func setupTimer() {
    timer = Timer(timeInterval: 2.0, repeats: true) { [weak self] _ in
        self?.handleTimerAction()
    }
    RunLoop.current.add(timer!, forMode: .common)
    timer?.fire()
}

func handleTimerAction() {
    let dataList = [text1, text2, text3, text4, text5]
    count += 1
    let index = count % 5
    textPlayView.text = dataList[index]
}

extension MWPipWindowVC: AVPictureInPictureControllerDelegate {
    func pictureInPictureControllerWillStartPictureInPicture(_ controller: AVPictureInPictureController) {
        if let window = UIApplication.shared.windows.first {
            setupTextPlayerView(on: window)
        }
    }
}

Performance Comparison

Converting a view to a video stream (CMSampleBuffer) consumes significantly more CPU than overlaying a view on a blank video, but the latter increases app bundle size due to the required placeholder video files. Choose the blank‑video overlay when only one PiP style is needed; otherwise, consider the view‑to‑video method for multiple customizable styles.

Conclusion

Both approaches achieve custom PiP UI on iOS. The blank‑video method is CPU‑friendly but adds assets, while the view‑to‑video method offers flexibility at higher CPU cost. Developers should select based on performance constraints and the need for multiple PiP designs.

iOSSwiftPicture-in-PictureTimerAVKitCustomPlayer
Sohu Tech Products
Written by

Sohu Tech Products

A knowledge-sharing platform for Sohu's technology products. As a leading Chinese internet brand with media, video, search, and gaming services and over 700 million users, Sohu continuously drives tech innovation and practice. We’ll share practical insights and tech news here.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.