[iOS][Swift]UISliderで押し始めと指を離した時点を知る

UISliderを使う際には、ユーザーアクションによる値の変化を知るには、たいていvalueChangedで間に合います。しかしながら、ときには、押し始めとツマミを離した時点を知りたいことがあります。


UISliderのisContinuousプロパティをtrueにした場合はユーザーがツマミを操作した際に連続的にeventが呼ばれます。押し始めで呼ばれ、動かしている際にも連続的に呼ばれます。
isContinuousプロパティをfalseにした場合はユーザーがツマミを離した時点でeventが呼ばれます。

今回は、isContinuousプロパティをfalseにした上で、押し始めとツマミを離した時点でeventを受けとるやりかたを紹介します。
isContinuousプロパティがfalseなので、valueChangedでは、押し始めはわかりません。
UISliderは、UIControlを継承していますので、他にもユーザーアクションeventを受け取ることができます。
UIControl.Event | Apple Developer Documentation
UIControl – iPhoneアプリ開発の虎の巻

Connections inspectorで使用できるEventが示され、接続できます

実装

UISliderをStoryboardのViewControlerに貼り付けます。

UISliderを適当な場所に貼り付けます

今回は、スライダーのisContinuousプロパティをfalseとします。Attributes inspectorでContinuous Updatesのチェックを外すことで、isCoutinuousプロパティをfalseにできます。

touchDownを実装して押し始めを受け取ります。
Storyboard上のスライダーとViewController.swiftをActionで結びます。

そのさい、Touch Downを選びます。
NameはsliderDidTouchDownとしました。
これで、スライダーのツマミの押し始めに、sliderDidTouchDownが呼ばれます。

また、スライダーとViewController.swiftをActionのValue Changedで結びます。

こちらは、スライダーとViewController.swiftをActionで結ぶときデフォルトで表示される動作です。
NameをsliderValueChangedとしました。
今回は、スライダーのisContinuousプロパティをfalseとしているので、ツマミを動かし終えて指を離した時にeventが送信され受け取ることができます。

import UIKit

class ViewController: UIViewController {
    
    var nowTouching = false
    
    // (略)
    
    
    @IBAction func sliderDidTouchDown(_ sender: Any) {
        print("touch down")
        nowTouching = true
    }
    
    @IBAction func sliderValueChanged(_ sender: UISlider) {
        print("value changed")
        nowTouching = false
    }
}

スライダー操作したさいに、どこでeventが送られ受け取るのかを確認するために、printを記しました。実行すると、ツマミに触った時点で「touch down」とツマミから指を離した時点で「value changed」とコンソールに表示されます。
また、ViewController.swiftのプロパティとしてnowTouchingを加えました。
ツマミに触った時点でnowTouchingをtrue、ツマミから指を離した時点でfalseとしています。これによって、スライダーを触っている最中かどうかを判定できます。


今回は、スライダーのisContinuousプロパティをfalseとして作成しました。
isContinuousプロパティをtrueにした場合は、ツマミに触れた時点でValue changedイベントが送信され、ツマミを移動しているあいだ連続的にValue changedイベントが送信されます。このときツマミから指を離したのを検知するには、Touch Up Inside、Touch Up Outsideイベントを用いてください。




[iOS]動画を再生する(バックグラウンドでの早戻り、早送り、シーク)

前回は、動画を再生・ポーズ・早送り・早戻し・シークする機能を実装しました。
今回は、バックグラウンドで、動画ファイルのオーディオを再生・ポーズ・早送り・早戻し・シークする機能を追加します。
以前、バックグラウンドで動画ファイルのオーディオを再生・ポーズする記事([iOS]動画を再生する(AVPlayerLayer使用))を記しました。今回は、早送り・早戻し・シークが加わっています。

前回の記事で作成したプロジェクトに書き加える形で作成します。
ポイントになる箇所について、説明をおこないました。
全体のソースコードは記事の最後にまとめてあります。

バックグラウンド再生の準備

今回のサンプルでは、バックグラウンドでのオーディオ再生を行うので、その設定を行います。
TARGET > Signing & Capabilitiesを選択。
「+ Capability」ボタンから-> Background Modes

Audio, AirPlay and Picture in Pictureをチェック。

これにより、info.plistにRequired background modesが加わり、そのitemがApp plays audio or streams audio/video using AirPlayとなります。バックグラウンド再生が可能になります。

リモートコントロール対応

イヤホンからの操作や、バックグラウンド移行後のコントロールセンターやロックスクリーンの再生コントロールからの操作に対応します。
MPRemoteCommandCenterクラスを用います。
また、コントロールセンターやロックスクリーンの再生コントロールに再生中アプリの現在の状況を表示する必要があります。それには、MPNowPlayingInfoCenterクラスを用います。
これらの実装については、Controlling Background Audio | Apple Developer Documentationが参考になります。

リモートコントロール対応部分


// MARK: Remote Command Event
func addRemoteCommandEvent() {
    
    let commandCenter = MPRemoteCommandCenter.shared()
    commandCenter.togglePlayPauseCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remoteTogglePlayPause(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    commandCenter.playCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remotePlay(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    commandCenter.pauseCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remotePause(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    
    commandCenter.nextTrackCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remoteNextTrack(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    commandCenter.previousTrackCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remotePrevTrack(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    
    // 早送り
    commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: skipInterval)]
    commandCenter.skipForwardCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remoteSkipForward(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    
    // 早戻し
    commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: skipInterval)]
    commandCenter.skipBackwardCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remoteSkipBackward(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    
    // ポジション移動(バーのボタン位置変更)
    commandCenter.changePlaybackPositionCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remoteChangePlaybackPosition(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    
    commandCenter.skipBackwardCommand.isEnabled = false
}
 
 /// イヤホンのセンターボタンを押した時の処理
 func remoteTogglePlayPause(_ event: MPRemoteCommandEvent) {
     // (略)
 }
 
 func remotePlay(_ event: MPRemoteCommandEvent) {
     player.play()
 }
 
 func remotePause(_ event: MPRemoteCommandEvent) {
     player.pause()
 }
 
 /// リモートコマンドで「次へ」ボタンが押された時の処理
 func remoteNextTrack(_ event: MPRemoteCommandEvent) {
     // (略)
 }
 
 /// リモートコマンドで「前へ」ボタンが押された時の処理
 func remotePrevTrack(_ event: MPRemoteCommandEvent) {
     // (略)
     
 }
 
 /// リモートコマンドで「早送り」ボタンが押された時の処理
 func remoteSkipForward(_ event: MPRemoteCommandEvent) {
     skipForward()
 }
 
 /// リモートコマンドで「早戻し」ボタンが押された時の処理
 func remoteSkipBackward(_ event: MPRemoteCommandEvent) {
     skipBackward()
 }

/// リモートコマンドでシークバー(プログレスバー)の位置を変更した時の処理
 func remoteChangePlaybackPosition(_ event: MPRemoteCommandEvent) {
     if let evt = event as? MPChangePlaybackPositionCommandEvent {
         let timeScale = CMTimeScale(NSEC_PER_SEC)
         let time = CMTime(seconds: evt.positionTime, preferredTimescale: timeScale)
         changePosition(time: time)
     }
 }

NowPlayingInfo部分

今回のサンプルでは、動画ファイルのurlの末尾部分がタイトルとして表示されます。
また、再生に連れてバーが進み、現在の再生時刻が表示されます。再生に連れて変化する箇所は、addPeriodicTimeObserver(forInterval:queue:using:)のblockに記述して、周期的に更新しています。
MPNowPlayingInfoCenterに示すことができるメタデータはほかにも様々なものがあります。
Now Playing Metadata Properties | MPNowPlayingInfoCenter – Media Player | Apple Developer Documentation

func setupNowPlaying(url: URL) {
    // Define Now Playing Info
    var nowPlayingInfo = [String : Any]()
    
    // ここでは、urlのlastPathComponentを表示しています。
    nowPlayingInfo[MPNowPlayingInfoPropertyAssetURL] = url
    nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue
    nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = false
    nowPlayingInfo[MPMediaItemPropertyTitle] = url.lastPathComponent
    nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = ""
    
    // Set the metadata
    MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}

func updateNowPlaying(time: Double) {
    var nowPlayingInfo = [String : Any]()
    nowPlayingInfo[MPMediaItemPropertyTitle] = itemURL?.lastPathComponent ?? ""
    nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time
    if let duration = player.currentItem?.duration {
        nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = CMTimeGetSeconds(duration)
    }
    MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}

再生コントロール

今回のサンプルでのロックスクリーン上の再生コントロール

再生ボタン、早戻しボタン、早送りボタンが表示されています。
ここで、「次のトラック」「前のトラック」ボタンのほうを表示したい時には、

let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.skipForwardCommand.isEnabled = false
commandCenter.skipBackwardCommand.isEnabled = false

とすると、skipForwardCommandとskipBackwardCommandが使用不可になり、再生コントロールにも表示されません。以下のようになります。
代わって、previousTrackCommandとnextTrackCommandを示すボタンが表示されています。

使用したソースコード

PlayerView.swift

import UIKit
import AVFoundation

class PlayerView: UIView {
    
    // The player assigned to this view, if any.
    
    var player: AVPlayer? {
        get { return playerLayer.player }
        set { playerLayer.player = newValue }
    }
    
    // The layer used by the player.
    
    var playerLayer: AVPlayerLayer {
        return layer as! AVPlayerLayer
    }
    
    // Set the class of the layer for this view.
    override static var layerClass: AnyClass {
        return AVPlayerLayer.self
    }
}

ViewController.swift

import UIKit
import AVFoundation
import MediaPlayer
import AVKit

class ViewController: UIViewController {

    @IBOutlet weak var playerView: PlayerView!
    @IBOutlet weak var slider: UISlider!
    
    let skipInterval: Double = 10
    
    var player = AVPlayer()
    var timeObserverToken: Any?
    
    var itemURL: URL?
    var itemDuration: Double = 0
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        setupAudioSession()
        
        setupPlayer()
        
        addRemoteCommandEvent()
    }
    
    private func setupAudioSession() {
        let audioSession = AVAudioSession.sharedInstance()
        do {
            try audioSession.setCategory(.playback, mode: .moviePlayback)
        } catch {
            print("Setting category to AVAudioSessionCategoryPlayback failed.")
        }
        do {
            try audioSession.setActive(true)
            print("audio session set active !!")
        } catch {
            
        }
    }
    
    private func setupPlayer() {
        playerView.player = player
        addPeriodicTimeObserver()
        
        replacePlayerItem(fileName: "clip", fileExtension: "mp4")
        
    }

    private func replacePlayerItem(fileName: String, fileExtension: String) {
        guard let url = Bundle.main.url(forResource: fileName, withExtension: fileExtension) else {
            print("Url is nil")
            return
        }
    
        itemURL = url
        let asset = AVAsset(url: url)
        itemDuration = CMTimeGetSeconds(asset.duration)
    
        let item = AVPlayerItem(url: url)
        player.replaceCurrentItem(with: item)
    
        setupNowPlaying(url: url)
    }

    @IBAction func playBtnTapped(_ sender: Any) {
        player.play()
    }
    
    @IBAction func pauseBtnTapped(_ sender: Any) {
        player.pause()
    }
    
    @IBAction func sliderValueChanged(_ sender: UISlider) {
        let seconds = Double(sender.value) * itemDuration
        let timeScale = CMTimeScale(NSEC_PER_SEC)
        let time = CMTime(seconds: seconds, preferredTimescale: timeScale)
        
        changePosition(time: time)
    }
    
    @IBAction func skipForwardBtnTapped(_ sender: Any) {
        skipForward()
    }
    
    @IBAction func skipBackwardBtnTapped(_ sender: Any) {
        skipBackward()
    }
    
    private func skipForward() {
        skip(interval: skipInterval)
    }
    
    private func skipBackward() {
        skip(interval: -skipInterval)
    }
    
    private func skip(interval: Double) {
        let timeScale = CMTimeScale(NSEC_PER_SEC)
        let rhs = CMTime(seconds: interval, preferredTimescale: timeScale)
        let time = CMTimeAdd(player.currentTime(), rhs)
        
        changePosition(time: time)
    }
    
    private func updateSlider() {
        let time = player.currentItem?.currentTime() ?? CMTime.zero
        if itemDuration != 0 {
            slider.value = Float(CMTimeGetSeconds(time) / itemDuration)
        }
    }
    
    private func changePosition(time: CMTime) {
        let rate = player.rate
        // いったんplayerをとめる
        player.rate = 0
        // 指定した時間へ移動
        player.seek(to: time, completionHandler: {_ in
            // playerをもとのrateに戻す(0より大きいならrateの速度で再生される)
            self.player.rate = rate
        })
    }
    
    
    // MARK: Now Playing Info
    func setupNowPlaying(url: URL) {
        // Define Now Playing Info
        var nowPlayingInfo = [String : Any]()
        
        // ここでは、urlのlastPathComponentを表示しています。
        nowPlayingInfo[MPNowPlayingInfoPropertyAssetURL] = url
        nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue
        nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = false
        nowPlayingInfo[MPMediaItemPropertyTitle] = url.lastPathComponent
        nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = ""
        
        // Set the metadata
        MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
    }
    
    func updateNowPlaying(time: Double) {
        var nowPlayingInfo = [String : Any]()
        nowPlayingInfo[MPMediaItemPropertyTitle] = itemURL?.lastPathComponent ?? ""
        nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time
        if let duration = player.currentItem?.duration {
            nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = CMTimeGetSeconds(duration)
        }
        MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
    }
    
    
    // MARK: Periodic Time Observer
    func addPeriodicTimeObserver() {
        // Notify every half second
        let timeScale = CMTimeScale(NSEC_PER_SEC)
        let time = CMTime(seconds: 0.5, preferredTimescale: timeScale)
        
        timeObserverToken = player.addPeriodicTimeObserver(forInterval: time,
                                                           queue: .main)
        { [weak self] time in
            // update player transport UI
            DispatchQueue.main.async {
                // sliderを更新
                self?.updateSlider()
                
                // NowPlayingInfoCenterを更新
                self?.updateNowPlaying(time: CMTimeGetSeconds(time))
            }
        }
    }

    func removePeriodicTimeObserver() {
        if let timeObserverToken = timeObserverToken {
            player.removeTimeObserver(timeObserverToken)
            self.timeObserverToken = nil
        }
    }
    
    // MARK: Remote Command Event
    func addRemoteCommandEvent() {
        
        let commandCenter = MPRemoteCommandCenter.shared()
        commandCenter.togglePlayPauseCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remoteTogglePlayPause(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        commandCenter.playCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remotePlay(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        commandCenter.pauseCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remotePause(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        
        commandCenter.nextTrackCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remoteNextTrack(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        commandCenter.previousTrackCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remotePrevTrack(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        
        // 早送り
        commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: skipInterval)]
        commandCenter.skipForwardCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remoteSkipForward(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        
        // 早戻し
        commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: skipInterval)]
        commandCenter.skipBackwardCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remoteSkipBackward(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        
        // ポジション移動(バーのボタン位置変更)
        commandCenter.changePlaybackPositionCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remoteChangePlaybackPosition(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
    }
     
     /// イヤホンのセンターボタンを押した時の処理
     func remoteTogglePlayPause(_ event: MPRemoteCommandEvent) {
         // (略)
     }
     
     func remotePlay(_ event: MPRemoteCommandEvent) {
         player.play()
     }
     
     func remotePause(_ event: MPRemoteCommandEvent) {
         player.pause()
     }
     
     /// リモートコマンドで「次へ」ボタンが押された時の処理
     func remoteNextTrack(_ event: MPRemoteCommandEvent) {
         // (略)
     }
     
     /// リモートコマンドで「前へ」ボタンが押された時の処理
     func remotePrevTrack(_ event: MPRemoteCommandEvent) {
         // (略)
         
     }
     
     /// リモートコマンドで「早送り」ボタンが押された時の処理
     func remoteSkipForward(_ event: MPRemoteCommandEvent) {
         skipForward()
     }
     
     /// リモートコマンドで「早戻し」ボタンが押された時の処理
     func remoteSkipBackward(_ event: MPRemoteCommandEvent) {
         skipBackward()
     }

    /// リモートコマンドでシークバー(プルグレスバー)の位置を変更した時の処理
     func remoteChangePlaybackPosition(_ event: MPRemoteCommandEvent) {
         if let evt = event as? MPChangePlaybackPositionCommandEvent {
             let timeScale = CMTimeScale(NSEC_PER_SEC)
             let time = CMTime(seconds: evt.positionTime, preferredTimescale: timeScale)
             changePosition(time: time)
         }
     }
    
}

[iOS]動画を再生する(早戻り、早送り、シーク)

(Xcode 11, Swift 5.1, iOS 13での実装)

iOSで、動画を再生する機能を作成します。

AVPlayer – AVFoundation | Apple Developer Documentation

AVPlayerクラスを用いることで、映像やオーディオの再生ができます。
AVPlayerには画面に表示する機能がありません。
以前、画面表示にAVPlayerViewControllerを用いたものAVPlayerLayerを用いたものを作成しました。
AVPlayerViewControllerは画面表示だけでなく、再生コントロール一式があらかじめ備わっています。
AVPlayerLayerを用いたものでは、再生・ポーズを実装しました。
今回はAVPlayerLayerを用いて、再生・ポーズに加えて早戻し、早送り、シークバー(プログレスバー)でのシーク機能を実装します。

AVPlayerLayer – AVFoundation | Apple Developer Documentation

AVPlayerLayerはplayerプロパティを持ち、そこにAVPlayerを渡すことで映像を表示できます。
実際に使う際には、このAVPlayerLayerのドキュメントに記されているプレイヤー用のUIViewサブクラスを用いる方法が扱いやすいです。

class PlayerView: UIView {
    var player: AVPlayer? {
        get {
            return playerLayer.player
        }
        set {
            playerLayer.player = newValue
        }
    }
    
    var playerLayer: AVPlayerLayer {
        return layer as! AVPlayerLayer
    }
    
    // Override UIView property
    override static var layerClass: AnyClass {
        return AVPlayerLayer.self
    }
}

Appleのドキュメントに乗っているコード例です。layerClassをオーバーライドしてこのUIViewがもつlayerはAVPlayerLayerクラスを返すようにしています。プロパティとしてplayerがあり、そこにAVPlayerをセットします。

この記事では、画面表示にこのPlayerViewを用いて、プロジェクトのリソースに加えた動画ファイルを再生する方法を記します。

動画を再生するサンプル

プロジェクトに追加してある動画ファイルを再生・一時停止・早戻し・早送り・シークバーでのシークを行うサンプルを作成しました。全体のソースコードは記事の最後にまとめてあります。
ポイントになる箇所について、説明をおこないました。
(今回の記事ではバックグラウンドでのオーディオ再生機能は実装していません。次の記事で実装しています。[iOS]動画を再生する(バックグラウンドでの早戻り、早送り、シーク)

動画再生機能作成については、Appleのサンプルが参考になります。
Creating a Movie Player App with Basic Playback Controls | Apple Developer Documentation

PlayerViewクラスを作成

今回は、ViewController.swiftのほかに、PlayerView.swiftを作成しました。
File > New > File > Cocoa Touch Classから、UIViewをスーパークラスとするPlayerViewクラスを作成します。
内容は、前段に記したAppleのドキュメントに載っているPlayerViewコード例の通りです。記事の最後にも載せています。

動画ファイルをプロジェクトに加える

File > Add Files to <プロジェクト名>。
動画ファイルを選び、プロジェクトに加えます。
今回は、clip.mp4という名前の動画ファイルが加えられたとして話を進めました。

UI作成

Main.storyboard > View Controller。
UIViewとボタン4つとSliderを貼り付けます。
(画像ではUIViewのBackgroundはオレンジ色にしてありますが、これはどこに貼り付けたかを見やすくするためで、実際にはSystem Background Colorのままで構いません)

貼り付けたUIViewを選択し、Identity inspector > Custom Class > Classで、PlayerViewとします。

Player ViewとViewController.swiftをOutletで結び、名前をplayerViewとします。

4つのボタンのテキストを「Play」「Pause」「Skip Forward」「Skip Backward」とします。
PlayボタンとViewController.swiftをActionで結び、名前をplayBtnTappedとします。
PauseボタンとViewController.swiftをActionで結び、名前をpauseBtnTappedとします。
Skip ForwardボタンとViewController.swiftをActionで結び、名前をskipForwardBtnTappedとします。
Skip BackwardボタンとViewController.swiftをActionで結び、名前をskipBackwardBtnTappedとします。

SliderとViewController.swiftをOutletで結び、名前をsliderとします。
SliderとViewController.swiftをActionで結び、名前をsliderValueChangedとします。
そのさい、TypeにはUISliderを選びます。
@IBAction func sliderValueChanged(_ sender: UISlider) {}が生成されます。スライダーの位置を変更した時に呼ばれます。

sliderのValueを0にします。

sliderのAttributes indicatorでContinuous Updatesのチェックを外します。
Continuous Updatesをチェックしているとスライダーのボタンを押している間、Eventが何度も送信されます。
外すと、スライダーのボタンを押して動かした後ボタンから指が離れた時にEventが送信されます。

Audio Sessionの準備

AVAudioSession – AVFoundation | Apple Developer Documentation

Audio Sessionとはアプリがどのようにオーディオを扱うかを示すオブジェクトです。再生のみなのか、録音再生を行うのか、バックグラウンド再生ありにするのかなどを設定します。用意されたカテゴリ・モード・オプションから適切なものを選んで設定する形式になっています。
今回は、カテゴリをAVAudioSessionCategoryPlayback(オーディオ機能をメインとしたアプリ用カテゴリ。バックグラウンド再生可能)、モードをAVAudioSessionModeMoviePlayback(ムービー再生用)に設定します。

/// Audio sessionを動画再生向けのものに設定し、activeにします
let audioSession = AVAudioSession.sharedInstance()
do {
    try audioSession.setCategory(.playback, mode: .moviePlayback)
        
} catch {
    print("Setting category to AVAudioSessionCategoryPlayback failed.")
}

do {
    try audioSession.setActive(true)
    print("Audio session set active !!")
} catch {
    
}

動画プレイヤー

AVPlayerを動画表示を担当するAVPlayerLayerと結びつけます。PlayerViewのプロパティであるplayerにAVPlayerをセットします。これによって、PlayerViewの動画表示をになうlayerと結びつけることができます。

playerView.player = player

urlからAVPlayerItemを作成します。AVPlayerに作成したAVPlayerItemをセットします。

let fileName = "clip"
let fileExtension = "mp4"
guard let url = Bundle.main.url(forResource: fileName, withExtension: fileExtension) else {
    print("Url is nil")
    return
}

let item = AVPlayerItem(url: url)
player.replaceCurrentItem(with: item)

ViewControllerクラスに追加したitemDurationプロパティに動画ファイルの長さを示す秒数を渡しています。これは、スライダーの操作のさいに用います。

let asset = AVAsset(url: url)
itemDuration = CMTimeGetSeconds(asset.duration)

早戻し、早送り、シーク

AVPlayerで、指定した時間に移動するには、
seek(to:)
seek(to:completionHandler:)
メソッドを使います。
(シークについてのAppleのドキュメントはこちら
Seeking Through Media | Apple Developer Documentation
早送りボタンを押した時は、その時の時刻から10秒なり15秒なり指定した秒数を足した時刻へ移動させます。
今回のサンプルでは、changePosition(time:)で指定した時間に移動して元の再生速度で再開する処理を行っています。

private func changePosition(time: CMTime) {
    let rate = player.rate
    // いったんplayerをとめる
    player.rate = 0
    // 指定した時間へ移動
    player.seek(to: time, completionHandler: {_ in
        // playerをもとのrateに戻す(0より大きいならrateの速度で再生される)
        self.player.rate = rate
    })
}

再生にあわせてプログレスバーを進める

再生にあわせてプログレスバーを進める必要があります。
AVPlayerのメソッドで、時間変化にあわせて周期的に処理を実行する
addPeriodicTimeObserver(forInterval:queue:using:)を用います。
intervalで指定した時間毎に、blockに記述した処理を実行します。
今回は、0.5秒毎にプログレスバーの位置を更新します。
また、addPeriodicTimeObserver(forInterval:queue:using:)のblockは、時刻がジャンプした時にも呼ばれます。今回のサンプルでは、早送り・早戻し・スライダーの操作でseekが行われ、時刻がジャンプします。そのさいにも呼ばれます。
AVPlayerでの再生時間の監視に関してはこちらのドキュメントが参考になります。
(Observing the Playback Time | Apple Developer Documentation)

func addPeriodicTimeObserver() {
    // Notify every half second
    let timeScale = CMTimeScale(NSEC_PER_SEC)
    let time = CMTime(seconds: 0.5, preferredTimescale: timeScale)
    
    timeObserverToken = player.addPeriodicTimeObserver(forInterval: time,
                                                       queue: .main)
    { [weak self] time in
        // update player transport UI
        DispatchQueue.main.async {
            // sliderを更新
            self?.updateSlider()
        }
    }
}

使用したソースコード

PlayerView.swift

import UIKit
import AVFoundation

class PlayerView: UIView {
    
    // The player assigned to this view, if any.
    
    var player: AVPlayer? {
        get { return playerLayer.player }
        set { playerLayer.player = newValue }
    }
    
    // The layer used by the player.
    
    var playerLayer: AVPlayerLayer {
        return layer as! AVPlayerLayer
    }
    
    // Set the class of the layer for this view.
    override static var layerClass: AnyClass {
        return AVPlayerLayer.self
    }
}

ViewController.swift

import UIKit
import AVFoundation
import MediaPlayer
import AVKit

class ViewController: UIViewController {

    @IBOutlet weak var playerView: PlayerView!
    @IBOutlet weak var slider: UISlider!
    
    let skipInterval: Double = 10
    
    var player = AVPlayer()
    var timeObserverToken: Any?
    
    var itemDuration: Double = 0
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        setupAudioSession()
        
        setupPlayer()
    }
    
    private func setupAudioSession() {
        let audioSession = AVAudioSession.sharedInstance()
        do {
            try audioSession.setCategory(.playback, mode: .moviePlayback)
        } catch {
            print("Setting category to AVAudioSessionCategoryPlayback failed.")
        }
        do {
            try audioSession.setActive(true)
            print("audio session set active !!")
        } catch {
            
        }
    }
    
    private func setupPlayer() {
        playerView.player = player
        addPeriodicTimeObserver()
        
        replacePlayerItem(fileName: "clip", fileExtension: "mp4")
        
    }

    private func replacePlayerItem(fileName: String, fileExtension: String) {
        guard let url = Bundle.main.url(forResource: fileName, withExtension: fileExtension) else {
            print("Url is nil")
            return
        }
    
        let asset = AVAsset(url: url)
        itemDuration = CMTimeGetSeconds(asset.duration)
    
        let item = AVPlayerItem(url: url)
        player.replaceCurrentItem(with: item)
    
   
    }
  

    @IBAction func playBtnTapped(_ sender: Any) {
        player.play()
    }
    
    @IBAction func pauseBtnTapped(_ sender: Any) {
        player.pause()
    }
    
    @IBAction func sliderValueChanged(_ sender: UISlider) {
        let seconds = Double(sender.value) * itemDuration
        let timeScale = CMTimeScale(NSEC_PER_SEC)
        let time = CMTime(seconds: seconds, preferredTimescale: timeScale)
        
        changePosition(time: time)
    }
    
    @IBAction func skipForwardBtnTapped(_ sender: Any) {
        skipForward()
    }
    
    @IBAction func skipBackwardBtnTapped(_ sender: Any) {
        skipBackward()
    }
    
    private func skipForward() {
        skip(interval: skipInterval)
    }
    
    private func skipBackward() {
        skip(interval: -skipInterval)
    }
    
    private func skip(interval: Double) {
        let timeScale = CMTimeScale(NSEC_PER_SEC)
        let rhs = CMTime(seconds: interval, preferredTimescale: timeScale)
        let time = CMTimeAdd(player.currentTime(), rhs)
        
        changePosition(time: time)
    }
    
    private func updateSlider() {
        let time = player.currentItem?.currentTime() ?? CMTime.zero
        if itemDuration != 0 {
            slider.value = Float(CMTimeGetSeconds(time) / itemDuration)
        }
    }
    
    private func changePosition(time: CMTime) {
        let rate = player.rate
        // いったんplayerをとめる
        player.rate = 0
        // 指定した時間へ移動
        player.seek(to: time, completionHandler: {_ in
            // playerをもとのrateに戻す(0より大きいならrateの速度で再生される)
            self.player.rate = rate
        })
    }
    
    // MARK: Periodic Time Observer
    func addPeriodicTimeObserver() {
        // Notify every half second
        let timeScale = CMTimeScale(NSEC_PER_SEC)
        let time = CMTime(seconds: 0.5, preferredTimescale: timeScale)
        
        timeObserverToken = player.addPeriodicTimeObserver(forInterval: time,
                                                           queue: .main)
        { [weak self] time in
            // update player transport UI
            DispatchQueue.main.async {
                print("update timer:\(CMTimeGetSeconds(time))")
                // sliderを更新
                self?.updateSlider()
            }
        }
    }

    func removePeriodicTimeObserver() {
        if let timeObserverToken = timeObserverToken {
            player.removeTimeObserver(timeObserverToken)
            self.timeObserverToken = nil
        }
    }
   
}

iOS 13.3でINUIAddVoiceShortcutViewControllerが日本語表示された

iOS 13.3がリリースされました。

iOS 13.3 | iOS 13 のアップデートについて – Apple サポート

iOS & iPadOS 13.3 Release Notes | Apple Developer Documentation

iOS 13.2.3までは、「Siriに追加」ボタンを押したときに表示される画面が英語のままで困っていました。

iOS 13.2.3でのINUIAddVoiceShortcutViewController

この画面でフレーズを入力修正して「Add to Siri」をタップすることで、Siriに登録できます。しかし、日本語を使用言語にしていても、英語で説明が表示されていました。

それが、iOS 13.3を入れたiPhoneで見てみたところ、

iOS 13.3でのINUIAddVoiceShortcutViewController

となって、日本語表示されていました!
設定の「言語と地域」のiPhoneの使用言語で設定した言語でローカライズされるようになりました。

[iOS]動画を再生する(AVPlayerLayer使用)

(Xcode 11, Swift 5.1, iOS 13での実装)

iOSでの、動画を再生する機能を、前回はAVPlayerViewControllerを用いて作成しました。今回は、AVPlayerLayerを使用します。

AVPlayer – AVFoundation | Apple Developer Documentation

AVPlayerクラスを用いることで、映像やオーディオの再生ができます。
AVPlayerには画面に表示する機能がないので、前回はその部分をAVPLayerViewControllerがになう例を記しました。
今回は、AVPlayerLayerを使う例を記しました。

AVPlayerLayer – AVFoundation | Apple Developer Documentation

AVPlayerLayerはplayerプロパティを持ち、そこにAVPlayerを渡すことで映像を表示できます。
実際に使う際には、このAVPlayerLayerのドキュメントに記されているプレイヤー用のUIViewサブクラスを用いる方法が扱いやすいです。

class PlayerView: UIView {
    var player: AVPlayer? {
        get {
            return playerLayer.player
        }
        set {
            playerLayer.player = newValue
        }
    }
    
    var playerLayer: AVPlayerLayer {
        return layer as! AVPlayerLayer
    }
    
    // Override UIView property
    override static var layerClass: AnyClass {
        return AVPlayerLayer.self
    }
}

Appleのドキュメントに乗っているコード例です。layerClassをオーバーライドしてこのUIViewがもつlayerはAVPlayerLayerクラスを返すようにしています。プロパティとしてplayerがあり、そこにAVPlayerをセットします。

この記事では、画面表示にこのPlayerViewを用いて、プロジェクトのリソースに加えた動画ファイルを再生する方法を記します。

動画を再生するサンプル

プロジェクトに追加してある動画ファイルを再生・一時停止するサンプルを作成しました。バックグラウンドへ移行してもオーディオの再生・一時停止ができます。バックグラウンド移行時に動画再生中であった場合、一時停止するものを作成しますが、一時停止せずにオーディオを再生し続ける方法についても記しました。全体のソースコードは記事の最後にまとめてあります。
ポイントになる箇所について、説明をおこないました。
(今回の記事では再生・一時停止のみですが、その後、早戻し・早送り・シークを含むサンプルを作成する記事を書きました。[iOS]動画を再生する(早戻り、早送り、シーク)

動画再生機能作成については、Appleのサンプルが参考になります。
Creating a Movie Player App with Basic Playback Controls | Apple Developer Documentation

PlayerViewクラスを作成

今回は、ViewController.swiftのほかに、PlayerView.swiftを作成しました。
File > New > File > Cocoa Touch Classから、UIViewをスーパークラスとするPlayerViewクラスを作成します。
内容は、前段に記したAppleのドキュメントに載っているPlayerViewコード例の通りです。記事の最後にも載せています。

動画ファイルをプロジェクトに加える

File > Add Files to <プロジェクト名>。
動画ファイルを選び、プロジェクトに加えます。
今回は、clip.mp4という名前の動画ファイルが加えられたとして話を進めました。

UI作成

Main.storyboard > View Controller。
UIViewとボタン二つを貼り付けます。
ボタンのテキストを「Play」「Pause」とします。
(画像ではUIViewのBackgroundはオレンジ色にしてありますが、これはどこに貼り付けたかを見やすくするためで、実際にはSystem Background Colorのままで構いません)

貼り付けたUIViewを選択し、Identity inspector > Custom Class > Classで、PlayerViewとします。

Player ViewとViewController.swiftをOutletで結び、名前をplayerViewとします。

一つのボタンのテキストを「Play」、もう一つのボタンのテキストを「Pause」とします。
PlayボタンとViewController.swiftをActionで結び、名前をplayBtnTappedとします。@IBAction func playBtnTapped(_ sender: Any) {}が生成されます。
PauseボタンとViewController.swiftをActionで結び、名前をpauseBtnTappedとします。@IBAction func pauseBtnTapped(_ sender: Any) {}が生成されます。

Audio Sessionの準備

AVAudioSession – AVFoundation | Apple Developer Documentation

Audio Sessionとはアプリがどのようにオーディオを扱うかを示すオブジェクトです。再生のみなのか、録音再生を行うのか、バックグラウンド再生ありにするのかなどを設定します。用意されたカテゴリ・モード・オプションから適切なものを選んで設定する形式になっています。
今回は、カテゴリをAVAudioSessionCategoryPlayback(オーディオ機能をメインとしたアプリ用カテゴリ。バックグラウンド再生可能)、モードをAVAudioSessionModeMoviePlayback(ムービー再生用)に設定します。

/// Audio sessionを動画再生向けのものに設定し、activeにします
let audioSession = AVAudioSession.sharedInstance()
do {
    try audioSession.setCategory(.playback, mode: .moviePlayback)
        
} catch {
    print("Setting category to AVAudioSessionCategoryPlayback failed.")
}

do {
    try audioSession.setActive(true)
    print("Audio session set active !!")
} catch {
    
}

バックグラウンド再生の準備

今回のサンプルでは、バックグラウンドでのオーディオ再生を行うので、その設定を行います。
TARGET > Signing & Capabilitiesを選択。
「+ Capability」ボタンから-> Background Modes

Audio, AirPlay and Picture in Pictureをチェック。

これにより、info.plistにRequired background modesが加わり、そのitemがApp plays audio or streams audio/video using AirPlayとなります。バックグラウンド再生が可能になります。

動画プレイヤー

urlからAVPlayerItemを作成します。
AVPlayerに作成したAVPlayerItemをセットします。
AVPlayerを動画表示を担当するAVPlayerLayerと結びつけます。
PlayerViewのプロパティであるplayerにAVPlayerをセットします。これによって、PlayerViewの動画表示をになうlayerと結びつけることができます。
今回は、ViewControllerクラスに追加したitemURLプロパティに動画のurlも渡しています。これは、あとで、NowPlayingInfo(現在再生中のアプリの状況)を表示するさいに使用します。

let fileName = "clip"
let fileExtension = "mp4"
guard let url = Bundle.main.url(forResource: fileName, withExtension: fileExtension) else {
    print("Url is nil")
    return
}

itemURL = url
let item = AVPlayerItem(url: url)
player = AVPlayer(playerItem: item)

playerView.player = player

リモートコントロール対応

イヤホンからの操作や、バックグラウンド移行後のコントロールセンターやロックスクリーンの再生コントロールからの操作に対応します。
MPRemoteCommandCenterクラスを用います。
また、コントロールセンターやロックスクリーンの再生コントロールに再生中アプリの現在の状況を表示する必要があります。それには、MPNowPlayingInfoCenterクラスを用います。
これらの実装については、Controlling Background Audio | Apple Developer Documentationが参考になります。

リモートコントロール対応部分

// MARK: Remote Command Event
func addRemoteCommandEvent() {
    
    let commandCenter = MPRemoteCommandCenter.shared()
    commandCenter.togglePlayPauseCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remoteTogglePlayPause(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    commandCenter.playCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remotePlay(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    commandCenter.pauseCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remotePause(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    commandCenter.nextTrackCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remoteNextTrack(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    commandCenter.previousTrackCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remotePrevTrack(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
}

func remoteTogglePlayPause(_ event: MPRemoteCommandEvent) {
    // イヤホンのセンターボタンを押した時の処理
    // (略)
}

func remotePlay(_ event: MPRemoteCommandEvent) {
    // 再生ボタンが押された時の処理
    // (略)
}

func remotePause(_ event: MPRemoteCommandEvent) {
    // ポーズボタンが押された時の処理
    // (略)
}

func remoteNextTrack(_ event: MPRemoteCommandEvent) {
    // 「次へ」ボタンが押された時の処理
    // (略)
}

func remotePrevTrack(_ event: MPRemoteCommandEvent) {
    // 「前へ」ボタンが押された時の処理
    // (略)
}

NowPlayingInfo部分

この例では、動画ファイルのurlの末尾部分をタイトル表示に使用しています。
MPNowPlayingInfoCenterに示すことができるメタデータはほかにも様々なものがあります。
Now Playing Metadata Properties | MPNowPlayingInfoCenter – Media Player | Apple Developer Documentation

func setupNowPlaying(url: URL) {
    // Define Now Playing Info
    var nowPlayingInfo = [String : Any]()
    
    // ここでは、urlのlastPathComponentをtitleとして表示しています。
    nowPlayingInfo[MPNowPlayingInfoPropertyAssetURL] = url
    nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue
    nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = false
    nowPlayingInfo[MPMediaItemPropertyTitle] = url.lastPathComponent
    nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = ""
    
    // Set the metadata
    MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}

バックグラウンド移行時の動作について

AVPlayerでは、動画再生中にバックグラウンドへ移行すると、一時停止するのがデフォルトの動作です。
もし、バックグラウンド移行時に再生しているオーディオを鳴らし続けたい場合は、追加の処理が必要になります。
これについては、Playing Audio from a Video Asset in the Background | Apple Developer Documentationのドキュメントに記されています。
バックグラウンド移行時にAVPlayerのインスタンスをplayerLayerから外す必要があります。

// フォアグラウンド移行時に呼び出されます
@objc func willEnterForeground(_ notification: Notification) {
    playerView.player = player
}
     
// バックグラウンド移行時に呼び出されます
@objc func didEnterBackground(_ notification: Notification) {
    playerView.player = nil
}

Playボタンを押すと動画が再生され、Pauseボタンを押すと動画が一時停止するサンプルを作成しました。イヤホン、コントロールセンターなどからの再生・一時停止もできます。

使用したソースコード

PlayerView.swift

import UIKit
import AVFoundation

class PlayerView: UIView {
    
    // The player assigned to this view, if any.
    
    var player: AVPlayer? {
        get { return playerLayer.player }
        set { playerLayer.player = newValue }
    }
    
    // The layer used by the player.
    
    var playerLayer: AVPlayerLayer {
        return layer as! AVPlayerLayer
    }
    
    // Set the class of the layer for this view.
    override static var layerClass: AnyClass {
        return AVPlayerLayer.self
    }
}

ViewController.swift

以下のソースコードでは、バックグラウンド移行時に一時停止する動作になっています。動画再生中のバックグラウンド移行のさいに、続けてオーディオ再生を行う場合は、willEnterForeground(_;)、didEnterBackground(_:)でのコメントアウトを外してください。

import UIKit
import AVFoundation
import MediaPlayer
import AVKit

class ViewController: UIViewController {

    @IBOutlet weak var playerView: PlayerView!
    
    var player = AVPlayer()
    var itemURL: URL?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        let audioSession = AVAudioSession.sharedInstance()
        do {
            try audioSession.setCategory(.playback, mode: .moviePlayback)
        } catch {
            print("Setting category to AVAudioSessionCategoryPlayback failed.")
        }
        do {
            try audioSession.setActive(true)
            print("audio session set active !!")
        } catch {
            
        }
        
        setupPlayer()
        
        addLifeCycleObserver()
        
        addRemoteCommandEvent()
    }
    
    private func setupPlayer() {
        let fileName = "clip"
        let fileExtension = "mp4"
        guard let url = Bundle.main.url(forResource: fileName, withExtension: fileExtension) else {
            print("Url is nil")
            return
        }
        
        itemURL = url
        let item = AVPlayerItem(url: url)
        player = AVPlayer(playerItem: item)
        
        playerView.player = player
        
    }

    /// Playボタンが押された
    @IBAction func playBtnTapped(_ sender: Any) {
        player.play()
        if let url = itemURL {
            setupNowPlaying(url: url)
        }
    }
        
    @IBAction func pauseBtnTapped(_ sender: Any) {
        player.pause()
    }
    
    // MARK: Life Cycle
    func addLifeCycleObserver() {
        let center = NotificationCenter.default
        center.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
        center.addObserver(self, selector: #selector(didEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil)
    }
    
    // フォアグラウンド移行時に呼び出されます
    @objc func willEnterForeground(_ notification: Notification) {
        /// (動画再生中であったときにそのままオーディオ再生を続ける場合は、このあとのコメントアウトをとりのぞいてください)
        /// (バックグラウンド移行時に、playerLayerからplayerがはずされているので、再度つなげます。)
        //playerView.player = player
    }
         
    // バックグラウンド移行時に呼び出されます
    @objc func didEnterBackground(_ notification: Notification) {
        /// (動画再生中であったときにそのままオーディオ再生を続ける場合は、このあとのコメントアウトをとりのぞいてください)
        //playerView.player = nil
    }
    
    // MARK: Now Playing Info
    func setupNowPlaying(url: URL) {
        // Define Now Playing Info
        var nowPlayingInfo = [String : Any]()
        
        // ここでは、urlのlastPathComponentを表示しています。
        nowPlayingInfo[MPNowPlayingInfoPropertyAssetURL] = url
        nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue
        nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = false
        nowPlayingInfo[MPMediaItemPropertyTitle] = url.lastPathComponent
        nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = ""
        
        // Set the metadata
        MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
    }
    
    // MARK: Remote Command Event
    func addRemoteCommandEvent() {
        
        let commandCenter = MPRemoteCommandCenter.shared()
        commandCenter.togglePlayPauseCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remoteTogglePlayPause(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        commandCenter.playCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remotePlay(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        commandCenter.pauseCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remotePause(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        commandCenter.nextTrackCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remoteNextTrack(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        commandCenter.previousTrackCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remotePrevTrack(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
    }
    
    func remoteTogglePlayPause(_ event: MPRemoteCommandEvent) {
        // イヤホンのセンターボタンを押した時の処理
        // (略)
    }
    
    func remotePlay(_ event: MPRemoteCommandEvent) {
        player.play()
    }
    
    func remotePause(_ event: MPRemoteCommandEvent) {
        player.pause()
    }

    func remoteNextTrack(_ event: MPRemoteCommandEvent) {
        // 「次へ」ボタンが押された時の処理
        // (略)
    }
    
    func remotePrevTrack(_ event: MPRemoteCommandEvent) {
        // 「前へ」ボタンが押された時の処理
        // (略)
        
    }

}

[iOS]動画を再生する(AVPlayerViewController使用)

(Xcode 11, Swift 5.1, iOS 13での実装)

iOSでの、動画を再生する機能の作成について記します。

AVPlayer – AVFoundation | Apple Developer Documentation

AVPlayerクラスを用いることで、映像やオーディオの再生ができます。
しかし、AVPlayerには画面に表示する機能はありません。
画面表示には、2つの方法があります。
 * AVPlayerViewControllerを使う。
 * AVPlayerLayerを使う。

AVPlayerViewControllerは、動画プレイヤー機能を簡単に作成できるクラスです。
iOS 8で追加され、iOSのアップデートごとに機能が拡充しています。
再生用のコントロールや、AirPlayサポート、Picture in Picture機能などが提供されます。

この記事では、画面表示にAVPlayerViewControllerを用いて、プロジェクトのリソースに加えた動画ファイルを再生する方法を記します。
(AVPlayerLayerを使った例はこちら

AVPlayerViewController – AVKit | Apple Developer Documentation

動画を再生するサンプル

動画再生機能作成については、Appleのドキュメントが参考になります。
Creating a Basic Video Player (iOS and tvOS) | Apple Developer Documentation

動画ファイルをプロジェクトに加える

File > Add Files to <プロジェクト名>。
動画ファイルを選び、プロジェクトに加えます。
今回は、clip.mp4という名前の動画ファイルが加えられたとして話を進めます。

UI作成

Main.storyboard > View Controllerにボタンを貼り付けます。
テキストを「Play Video」とします。

ボタンとViewControllerをActionで結び、NameをplayBtnTappedとします。@IBAction func playBtnTapped(_ sender: Any) {}が生成されます。

Audio Sessionの準備

AVAudioSession – AVFoundation | Apple Developer Documentation

Audio Sessionとはアプリがどのようにオーディオを扱うかを示すオブジェクトです。再生のみなのか、録音再生を行うのか、バックグラウンド再生ありにするのかなどを設定します。用意されたカテゴリ・モード・オプションから適切なものを選んで設定する形式になっています。
今回は、カテゴリをAVAudioSessionCategoryPlayback(オーディオ機能をメインとしたアプリ用カテゴリ。バックグラウンド再生可能)、モードをAVAudioSessionModeMoviePlayback(ムービー再生用)に設定します。

/// Audio sessionを動画再生向けのものに設定し、activeにします
let audioSession = AVAudioSession.sharedInstance()
do {
    try audioSession.setCategory(.playback, mode: .moviePlayback)
        
} catch {
    print("Setting category to AVAudioSessionCategoryPlayback failed.")
}

do {
    try audioSession.setActive(true)
    print("Audio session set active !!")
} catch {
    
}

バックグラウンド再生の準備

今回のサンプルでは、バックグラウンドでのオーディオ再生を行うので、その設定を行います。
TARGET > Signing & Capabilitiesを選択。
「+ Capability」ボタンから-> Background Modes

Audio, AirPlay and Picture in Pictureをチェック。

これにより、info.plistにRequired background modesが加わり、そのitemがApp plays audio or streams audio/video using AirPlayとなります。バックグラウンド再生が可能になります。

動画プレイヤー

AVPlayerにurlで指定された動画ファイルを渡します。
AVPlayerを動画の表示機能・再生コントロールを担当するAVPlayerViewControllerと結びつけます。
動画プレイヤーを再生します。

guard let url = Bundle.main.url(forResource: fileName, withExtension: fileExtension) else {
    print("Url is nil")
    return
}

// AVPlayerにアイテムをセット
let item = AVPlayerItem(url: url)
player.replaceCurrentItem(with: item)

// 動画プレイヤーにplayerをセット
playerController.player = player

// 動画プレイヤーを表示して再生
self.present(playerController, animated: true) {
    self.player.play()
}

以上で、「Play Video」ボタンを押すと、動画プレイヤーが起動し、動画が再生されます。再生コントロールが表示され、ポーズしたり再開したりできます。
画面下端からスワイプ(もしくはホームボタンを押す)ことで、バックグラウンドに移行すると、再生中の動画は一時停止します。コントロールセンターの再生コントロールから、再生を再開することができます。

使用したソースコード

ViewController.swift

import UIKit
import AVFoundation
import MediaPlayer
import AVKit

class ViewController: UIViewController {
 
    var playerController = AVPlayerViewController()
    var player = AVPlayer()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        /// Audio sessionを動画再生向けのものに設定し、activeにします
        let audioSession = AVAudioSession.sharedInstance()
        do {
            try audioSession.setCategory(.playback, mode: .moviePlayback)
                
        } catch {
            print("Setting category to AVAudioSessionCategoryPlayback failed.")
        }

        do {
            try audioSession.setActive(true)
            print("Audio session set active !!")
        } catch {
            
        }
        
    }

    /// 動画プレイヤーにアイテムをセットして更新
    private func playMovie(fileName: String, fileExtension: String) {
        guard let url = Bundle.main.url(forResource: fileName, withExtension: fileExtension) else {
            print("Url is nil")
            return
        }
        
        // AVPlayerにアイテムをセット
        let item = AVPlayerItem(url: url)
        player.replaceCurrentItem(with: item)
            
        // 動画プレイヤーにplayerをセット
        playerController.player = player
        
        // 動画プレイヤーを表示して再生
        self.present(playerController, animated: true) {
            self.player.play()
        }
    }
    
    /// Play Videoボタンが押されました
    @IBAction func playBtnTapped(_ sender: Any) {
        let fileName = "clip"
        let fileExtension = "mp4"
        playMovie(fileName: fileName, fileExtension: fileExtension)
    }
    
}

関連記事

[iOS]動画を再生する(AVPlayerLayer使用) – nackpan Blog

AVSpeechSynthesizerをバックグラウンド再生ありで使う

(2019/08/09更新。バックグラウンド移行時に音が止まっているならAudioSessionを無効化する方法に書き換えました。)

以前[iOS]電話がかかってきたとき、ヘッドホンジャックが抜かれたときの対応 – nackpan Blogという記事を書きました。

そこでのkkさんのコメントをきっかけにAVSpeechSynthesizerの使い方について調べました。

AVSpeechSynthesizerを用いてテキストを読み上げるアプリ。フォアグラウンドで再生している場合は問題ありません。
バックグラウンド再生を絡めた場合にトラブル。
AVSpeechSynthesizerでspeak中にpauseしたあとアプリを中断、再びアプリを開くと勝手に音が鳴るなど、アプリ再開時の挙動が妙でした。

ログを見るとアプリ再開時にinterruptionの通知が届いていたので、interruptionNotificationについて調査。

InterruptionNotificationについて

Interruptionの通知は、電話がかかってきた時などでAudioSessionに割り込みが発生した時に届くものと考えていました。
今回、アプリの再開時に、interruptionの通知が届きました。

Appleのドキュメントを見直しました。

interruptionNotification – AVAudioSession | Apple Developer Documentation

Noteとして以下の記述があります。

Starting in iOS 10, the system will deactivate the audio session of most apps in response to the app process being suspended. When the app starts running again, it will receive an interruption notification that its audio session has been deactivated by the system. This notification is necessarily delayed in time because it can only be delivered once the app is running again. If your app’s audio session was suspended for this reason, the userInfo dictionary will contain the AVAudioSessionInterruptionWasSuspendedKey key with a value of true.

If your audio session is configured to be non-mixable (the default behavior for the playback, playAndRecord, soloAmbient, and multiRoute categories), it’s recommended that you deactivate your audio session if you’re not actively using audio when you go into the background. Doing so will avoid having your audio session deactivated by the system (and receiving this somewhat confusing notification).

interruptionNotification – AVAudioSession | Apple Developer Documentation

 * iOS 10以降では、システムは、アプリプロセスが中断されると、ほとんどのアプリの AudioSessionを無効にする。
*  アプリの再開時に、AudioSessionがシステムによって無効にされたことを示す割り込み(interruption)通知を受け取る。
* この通知でAVAudioSessionInterruptionWasSuspendedKeyの値はtrue
* AudioSessionがミックス不可(playback、playAndRecord、soloAmbient、multiRouteのデフォルトの動作)設定なら、バックグラウンド移行時に、アクティブに使用していないAudioSessionを無効にすることをお勧め
 * そうすることで、この通知を受け取らなくて済む

とあるように、システムによってAudioSessionが無効にされたことを示すinterruptionがアプリの再開時に届く、とのことでした。

InterruptionNotificationの実験

バックグラウンド再生ありのAVSpeechSynthesizerを使ったアプリで、interruptionの通知について実験しました。
当初、実験した際(07/29)には、iOS 12.4で
* AVSpeechSynthesizerでspeak中に、pause。電源ボタンを押してアプリを中断。すると音声が勝手に鳴ってしまう。電源ボタンを押した直後にinterruptionの通知が届きました。(willResignActiveの前に発生)
という症状が発生しました。
しかし、その後あらためて実験すると、まったくこの症状は出ず、interruptionNotification – AVAudioSession のドキュメントにあるように再開後にinterruptionの通知が届きました。

対処法

アプリ再開時に音が勝手になるなど挙動が変になる問題は、システムによるAudioSessionの無効化が関係しているのだろう。AVSpeechSynthesizerでは再開時に上手いことやってくれない。Noteにあるように、明示的にAudioSessionを無効化すれば、通知を受け取らずに済むし挙動も適切なものになると考えました。

interruptionNotificationのNoteでは、アプリのAudioSessionが無効にされたことを示す通知がアプリ再開後に届くとあり、それを避けるには、バックグラウンド移行時にAudioSessionを無効化するとよい、とありました。


当初の実験(07/29)では、iOS 12.4での電源ボタンによるアプリの中断で、電源ボタンを押した直後に通知が届き、バックグラウンド移行時にAudioSessionを無効化する処理を書いても、音声が短いながらも勝手に鳴ってしまう症状が出ました。そのため、AVSpeechSynthesizerでの音声再生が終了したら、その段階でAudioSessionを無効化にするということで対処することにしていました。これにはAVSpeechSynthesizerDelegateを使いAVSpeechSynthesizerのpause, stop, finishの完了を知り、そこでAudioSessionを無効化にしていました。

しかし、その後の実験では「電源ボタンによるアプリの中断で、電源ボタンを押した直後に通知が届き、音声が短いながらも勝手に鳴ってしまう」症状は再現しませんでした。


そこで、interruptionNotificationのNoteが勧める方法に従いました。
バックグラウンド移行時に音が止まっている場合、AudioSessionを無効化。
speakのさいにAudioSessionを有効化しました。

バックグラウンド移行を検知する方法はこちらの記事が参考になります。
NotificationCenterを用いたライフサイクルイベントの検知

実装

今回はViewController.swiftにすべて記述することとしました。
メッセージラベル、Speakボタン、Pauseボタン、Stopボタンを貼り付けます。

アプリ画面

メッセージラベルとViewControllerをOutletで結びます。
@IBOutlet weak var messageLabel: UILabel!
SpeakボタンとViewControllerをActionで結びます。
@IBAction func speakBtnTapped(_ sender: Any) 
PauseボタンとViewControllerをActionで結びます。
@IBAction func pauseBtnTapped(_ sender: Any)
StopボタンとViewControllerをActionで結びます。
@IBAction func stopBtnTapped(_ sender: Any)

バックグラウンド再生用。
Capabilities > Background Modes をON > Audio, AirPlay, and Picture in Pictureにチェック

ViewController.swift

import UIKit
import AVFoundation
import AVKit
import MediaPlayer

class ViewController: UIViewController, AVSpeechSynthesizerDelegate {

    @IBOutlet weak var messageLabel: UILabel!
    var syntherizer = AVSpeechSynthesizer()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        // AudioSessionカテゴリをbackground再生ができるものに設定
        let audioSession = AVAudioSession.sharedInstance()
        do {
            try audioSession.setCategory(AVAudioSession.Category.playback)
        } catch {
            print("Setting category to AVAudioSessionCategoryPlayback failed.")
        }

        addAudioSessionObservers()
        addRemoteCommandEvent()
        addLifeCycleObserver()
    }
    
    // 電話による割り込みと、オーディオルートの変化を監視します
    func addAudioSessionObservers() {
        let center = NotificationCenter.default
        center.addObserver(self, selector: #selector(handleInterruption(_:)), name: AVAudioSession.interruptionNotification, object: nil)
        center.addObserver(self, selector: #selector(audioSessionRouteChanged(_:)), name: AVAudioSession.routeChangeNotification, object: nil)
    }
    
    /// Interruption : 電話などによる割り込み
    @objc func handleInterruption(_ notification: Notification) {
        guard let userInfo = notification.userInfo,
            let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
            let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
                return
        }
        
        if type == .began {
            // interruptionが開始した時(電話がかかってきたなど)
            if let wasSuspendedKeyValue = userInfo[AVAudioSessionInterruptionWasSuspendedKey] as? NSNumber {
                let wasSuspendedKey = wasSuspendedKeyValue.boolValue
                if wasSuspendedKey {
                    // suspeended key : true
                } else {
                    // suspended key : false
                }
            } else {
                // suspended key : nil
            }
        }
        else if type == .ended {
            // interruptionが終了した時の処理
            if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
                let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
                if options.contains(.shouldResume) {
                    // Interruption Ended - playback should resume
                } else {
                    // Interruption Ended - playback should NOT resume
                }
            }
        }
    }
    
    /// Audio Session Route Change : ルートが変化した(ヘッドフォンが抜き差しされた)
    @objc func audioSessionRouteChanged(_ notification: Notification) {
        guard let userInfo = notification.userInfo,
            let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
            let reason = AVAudioSession.RouteChangeReason(rawValue:reasonValue) else {
                return
        }
        
        DispatchQueue.main.async {
            self.messageLabel.text = self.routeChangeReasonDescription(reason: reason)
        }

        switch reason {
        case .newDeviceAvailable:
            let session = AVAudioSession.sharedInstance()
            for output in session.currentRoute.outputs where output.portType == AVAudioSession.Port.headphones {
                // ヘッドフォンがつながった
                
                break
            }
        case .oldDeviceUnavailable:
            if let previousRoute =
                userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription {
                for output in previousRoute.outputs where output.portType == AVAudioSession.Port.headphones {
                    // ヘッドフォンが外れた
                    
                    // 音声をpauseしています
                    pause()
                    break
                }
            }
        default: ()
        }
        
    }
    
    public func speak() {
        let audioSession = AVAudioSession.sharedInstance()
        do {
            try audioSession.setActive(true)
        } catch {
            
        }
        let voice = AVSpeechSynthesisVoice(language: "ja-JP")
        let utterance = AVSpeechUtterance(string: "おはようございます。今日もいい天気ですね。音声合成で読み上げています。文章をうまくよめていますか?")
        utterance.voice = voice
        utterance.preUtteranceDelay = 5
        
        if syntherizer.isPaused {
            syntherizer.continueSpeaking()
        } else {
            syntherizer.speak(utterance)
        }
    }
    
    public func pause() {
        syntherizer.pauseSpeaking(at: .immediate)
    }
    
    /// Speakボタンが押された
    @IBAction func speakBtnTapped(_ sender: Any) {
        // 音声出力を行う
        speak()
    }
    
    /// pauseボタンが押された
    @IBAction func pauseBtnTapped(_ sender: Any) {
        pause()
    }
    
    @IBAction func stopBtnTapped(_ sender: Any) {
        syntherizer.stopSpeaking(at: .immediate)
    }
    
    /// 監視する必要がなくなった段階で、Observerを取り外します
    private func removeAudioSessionObservers() {
        let center = NotificationCenter.default
        
        // AVAudio Session
        center.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
        center.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: nil)
        
    }
    
    /// route変更の理由表示用
    private func routeChangeReasonDescription(reason: AVAudioSession.RouteChangeReason) -> String {
        switch reason {
        case .unknown:
            return "unknown"
        case .newDeviceAvailable:
            return "newDeviceAvailable"
        case .oldDeviceUnavailable:
            return "oldDeviceUnailable"
        case .categoryChange:
            return "categoryChange"
        case .override:
            return "override"
        case .wakeFromSleep:
            return "wakeFromSleep"
        case .noSuitableRouteForCategory:
            return "noSuitableRouteForCategory"
        case .routeConfigurationChange:
            return "routeConfigurationChange"
        default:
            return "default"
        }
    }
    
    // MARK: Remote Command Event
    func addRemoteCommandEvent() {
        let commandCenter = MPRemoteCommandCenter.shared()
        commandCenter.togglePlayPauseCommand.addTarget(self, action: #selector(type(of: self).remoteTogglePlayPause(_:)))
        commandCenter.playCommand.addTarget(self, action: #selector(type(of: self).remotePlay(_:)))
        commandCenter.pauseCommand.addTarget(self, action: #selector(type(of: self).remotePause(_:)))
        commandCenter.nextTrackCommand.addTarget(self, action: #selector(type(of: self).remoteNextTrack(_:)))
        commandCenter.previousTrackCommand.addTarget(self, action: #selector(type(of: self).remotePrevTrack(_:)))
    }
    
    @objc func remoteTogglePlayPause(_ event: MPRemoteCommandEvent) {
        // イヤホンのセンターボタンを押した時の処理
        // 略

    }
    
    @objc func remotePlay(_ event: MPRemoteCommandEvent) {
        // プレイボタンが押された時の処理
        speak()

    }
    
    @objc func remotePause(_ event: MPRemoteCommandEvent) {
        // ポーズボタンが押された時の処理
        pause()
        
    }
    
    @objc func remoteNextTrack(_ event: MPRemoteCommandEvent) {
        // 「次へ」ボタンが押された時の処理
        // (略)
    }
    
    @objc func remotePrevTrack(_ event: MPRemoteCommandEvent) {
        // 「前へ」ボタンが押された時の処理
        // (略)
        
    }
    
    // MARK: - Life Cycle
    func addLifeCycleObserver() {
        let center = NotificationCenter.default
        // 今回はbackground移行を検知
        center.addObserver(self, selector: #selector(didEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil)
    }
    
    @objc func didEnterBackground(_ notification: Notification) {
        // 音が止まっていたらAudioSessionを無効化
        if !syntherizer.isSpeaking || syntherizer.isPaused {
            deactivateAudioSession()
        }

        let app = UIApplication.shared
        app.beginBackgroundTask(expirationHandler: {
            // このblockはバックグラウンド処理に入って、所定時間後(180秒程度後)に実行される
            // (バックグラウンド再生が継続している間は実行されない)
            // ここでAudioSessionを無効化しておく
            // (この処理がないとバックグラウンド再生中にpauseしたあと、3分すぎにアプリを再開すると
            // いきなり音が鳴る症状が出てしまう)
            self.deactivateAudioSession()
            
        })
    }
    
    func deactivateAudioSession() {
        let audioSession = AVAudioSession.sharedInstance()
        do {
            try audioSession.setActive(false)
        } catch {
            
        }
    }
}

CoreGraphicsコードをSwift 3.0に変換する

Xcode 8.0が登場しました。
旧プロジェクトをXcode 8.0で開くと、Swift 3.0(あるいはSwift 2.4)に変換するよう促すダイアログが出現しました。

CoreGraphicsコードをSwift 3.0への自動コンバートにかけたところ、変換されない部分が残りました。

        let pathRef = CGMutablePath()
        CGPathMoveToPoint(pathRef, nil, 20, 0)
        CGPathAddLineToPoint(pathRef, nil, 200, 0)
        CGPathAddCurveToPoint(pathRef, nil, 205.523, 0, 210, 4.435, 210, 10)
        CGPathAddLineToPoint(pathRef, nil, 210, 122)
        CGPathAddCurveToPoint(pathRef, nil, 210, 127.565, 205.523, 132, 200, 132)
        CGPathAddLineToPoint(pathRef, nil, 10, 132)
        CGPathAddCurveToPoint(pathRef, nil, 4.477, 132, -0, 127.565, -0, 122)
        CGPathAddLineToPoint(pathRef, nil, -0, 20)
        CGPathAddCurveToPoint(pathRef, nil, -0, 9.087, 9.081, 0, 20, 0)
        pathRef.closeSubpath()

変換後、このようなコードになったのですが最終的には

        let pathRef = CGMutablePath()
        pathRef.move(to: CGPoint(x: 20, y: 0))
        pathRef.addLine(to: CGPoint(x: 200, y: 0))
        pathRef.addCurve(to: CGPoint(x: 210, y: 10), control1: CGPoint(x: 205.523, y: 0), control2: CGPoint(x: 210, y: 4.435))
        pathRef.addLine(to: CGPoint(x: 210, y: 122))
        pathRef.addCurve(to: CGPoint(x: 200, y: 132), control1: CGPoint(x: 210, y: 127.565), control2: CGPoint(x: 205.523, y: 132))
        pathRef.addLine(to: CGPoint(x: 10, y: 132))
        pathRef.addCurve(to: CGPoint(x: -0, y: 122), control1: CGPoint(x: 4.477, y: 132), control2: CGPoint(x: -0, y: 127.565))
        pathRef.addLine(to: CGPoint(x: -0, y: 20))
        pathRef.addCurve(to: CGPoint(x: 20, y: 0), control1: CGPoint(x: -0, y: 9.087), control2: CGPoint(x: 9.081, y: 0))
        pathRef.closeSubpath()

と変換したい。

文字列の処理がやりやすいのはなにかしらと考えて、急遽Rubyを学ぶことにしました。


文字列操作とファイル操作の基本的なやりかたを知ったので、以下の変換プログラム converter.rbを書きました。
CGPathMoveToPoint(pathRef, nil, 20, 0)

pathRef.move(to: CGPoint(x: 20, y: 0))

CGPathAddLineToPoint(pathRef, nil, 200, 0)

pathRef.addLine(to: CGPoint(x: 200, y: 0))

CGPathAddCurveToPoint(pathRef, nil, 205.523, 0, 210, 4.435, 210, 10)

pathRef.addCurve(to: CGPoint(x: 210, y: 10), control1: CGPoint(x: 205.523, y: 0), control2: CGPoint(x: 210, y: 4.435))

と変換するものです。

converter.rb

class Converter

    def convert()
        loop do 
            print "Filename? "
            fileName = gets.chomp
            
            if fileName == ""
                break
            end
            
            # Backup
            from = fileName
            to = "_" + fileName + ".bak"
            copy(from, to)
            
            # Conversion
            array = []
            File.open(fileName) do |file|
                file.each_line do |line|
                    line = convertCoreGraphicsCode(line)
                    array.push(line)
                end
            end
                
            # Writing
            File.open(fileName, "w") do |file|
                file.puts(array)
                puts fileName + " 変換終了"
            end
        end
    end
    
    def copy(from, to)
        File.open(from) do |input|
            File.open(to, "w") do |output|
                output.write(input.read)
            end
        end
    end

    def convertCoreGraphicsCode(line)
        if line.include?("CGPathMoveToPoint")
            return convertMoveToPoint(line)
        elsif line.include?("CGPathAddLineToPoint") 
            return convertAddLineToPoint(line)
        elsif line.include?("CGPathAddCurveToPoint")
            return convertAddCurveToPoint(line)
        else
            return line
        end
    end
    
    # CGPathMoveToPoint(clipPath, nil, 240, 122)
    # to
    # clipPath.move(to: CGPoint(x: 240, y: 122))
    def convertMoveToPoint(line)
        # indentを取得
        index = line.index("CGPathMoveToPoint")
        indent = line[0, index]
        
        # path名を含むかたまりを取得("CGPathMoveToPoint(clipPath,")
        pathStr = line.match(/CGPathMoveToPoint\(\w+,/)
        
        # path名を取得("clipPath")
        pathName = pathStr[0].sub("CGPathMoveToPoint\(", "").chop
        # puts pathName
        
        # 数値
        figuresStr = line.sub(/CGPathMoveToPoint\(\w+, nil,/, "").chomp.chop.lstrip
        figuresStr = " " + figuresStr
        figures = figuresStr.split(",")
        # puts figures
        
        dstStr = "%s%s.move(to: CGPoint(x:%s, y:%s))" % [indent, pathName, figures[0], figures[1]]
        # puts dstStr
        return dstStr
    end
    
    
    # CGPathAddLineToPoint(clipPath, nil, 240, 122)
    # to
    # clipPath.addLine(to: CGPoint(x: 240, y: 122))
    def convertAddLineToPoint(line)
        # indentを取得
        index = line.index("CGPathAddLineToPoint")
        indent = line[0, index]
        
        # path名を含むかたまりを取得("CGPathAddLineToPoint(clipPath,")
        pathStr = line.match(/CGPathAddLineToPoint\(\w+,/)
        
        # path名を取得("clipPath")
        pathName = pathStr[0].sub("CGPathAddLineToPoint\(", "").chop
        # puts pathName
        
        # 数値
        figuresStr = line.sub(/CGPathAddLineToPoint\(\w+, nil,/, "").chomp.chop.lstrip
        figuresStr = " " + figuresStr
        figures = figuresStr.split(",")
        # puts figures

        dstStr = "%s%s.addLine(to: CGPoint(x:%s, y:%s))" % [indent, pathName, figures[0], figures[1]]
        # puts dstStr
        return dstStr
    end
    
    # CGPathAddCurveToPoint(pathRef2, nil, 4.477, 132, 0, 127.565, 0, 122)
    # to
    # pathRef2.addCurve(to: CGPoint(x: 0, y: 122), control1: CGPoint(x: 4.477, y: 132), control2: CGPoint(x: 0, y: 127.565))
    def convertAddCurveToPoint(line)

        # indentを取得
        index = line.index("CGPathAddCurveToPoint")
        indent = line[0, index]
        
        # path名を含むかたまりを取得("CGPathAddCurveToPoint(pathRef2,")
        pathStr = line.match(/CGPathAddCurveToPoint\(\w+,/)
        
        # path名を取得("pathRef2")
        pathName = pathStr[0].sub("CGPathAddCurveToPoint\(", "").chop
        # puts pathName
        
        # 数値を取得
        figuresStr = line.sub(/CGPathAddCurveToPoint\(\w+, nil,/, "").chomp.chop.lstrip
        figuresStr = " " + figuresStr
        figures = figuresStr.split(",")
        # puts figures
        
        dstStr = "%s%s.addCurve(to: CGPoint(x:%s, y:%s), control1: CGPoint(x:%s, y:%s), control2: CGPoint(x:%s, y:%s))" % [indent, pathName, figures[4], figures[5], figures[0], figures[1], figures[2], figures[3]]
        # puts dstStr
        return dstStr 
    end

end

converter = Converter.new
converter.convert()

変換したいswiftファイルがあるフォルダに、converter.rbをおき、Terminalでそのフォルダへ移動した後、

ruby converter.rb  

と入力するとプログラムが実行されます。
実行すると、

FileName?  

とたずねられるので、そこで変換したいファイル名を入力すると変換が行われます。
(このさい、”_元ファイル名.bak”というバックアップファイルも作成します。)
ファイル名を入力せずにreturnキーを押すと、プログラムは終了します。

関連

Autodesk Graphic(旧iDraw)はCore Graphicsのコードを生成できる – nackpan Blog