Getting SwiftUI wrapper of AVPlayer to pause when view disappears

我是研究僧i 提交于 2021-01-28 00:45:09

问题


TL;DR

Can't seem to use binding to tell wrapped AVPlayer to stop — why not? The "one weird trick" from Vlad works for me, without state & binding, but why?

See Also

My question is something like this one but that poster wanted to wrap an AVPlayerViewController and I want to control playback programmatically.

This guy also wondered when updateUIView() was called.

What happens (Console logs shown below.)

With code as shown here,

  • The user taps "Go to Movie"

    • MovieView appears and the vid plays
    • This is because updateUIView(_:context:) is being called
  • The user taps "Go back Home"

    • HomeView reappears
    • Playback halts
    • Again updateUIView is being called.
    • See Console Log 1
  • But... remove the ### line, and

    • Playback continues even when the home view returns
    • updateUIView is called on arrival but not departure
    • See Console log 2
  • If you uncomment the %%% code (and comment out what precedes it)

    • You get code I thought was logically and idiomatically correct SwiftUI...
    • ...but "it doesn't work". I.e. the vid plays on arrival but continues on departure.
    • See Console log 3

The code

I do use an @EnvironmentObject so there is some sharing of state going on.

Main content view (nothing controversial here):

struct HomeView: View {
    @EnvironmentObject var router: ViewRouter

    var body: some View {
        ZStack() {  // +++ Weird trick ### fails if this is Group(). Wtf?
            if router.page == .home {
                Button(action: { self.router.page = .movie }) {
                    Text("Go to Movie")
                }
            } else if router.page == .movie {
                MovieView()
            }
        }
    }
}

which uses one of these (still routine declarative SwiftUI):

struct MovieView: View {
    @EnvironmentObject var router: ViewRouter
    // @State private var isPlaying: Bool = false  // %%%

    var body: some View {
        VStack() {
            PlayerView()
            // PlayerView(isPlaying: $isPlaying) // %%%
            Button(action: { self.router.page = .home }) {
                Text("Go back Home")
            }
        }.onAppear {
            print("> onAppear()")
            self.router.isPlayingAV = true
            // self.isPlaying = true  // %%%
            print("< onAppear()")
        }.onDisappear {
            print("> onDisappear()")
            self.router.isPlayingAV = false
            // self.isPlaying = false  // %%%
            print("< onDisappear()")
        }
    }
}

Now we get into the AVKit-specific stuff. I use the approach described by Chris Mash.

The aforementioned PlayerView, the wrappER:

struct PlayerView: UIViewRepresentable {
    @EnvironmentObject var router: ViewRouter
    // @Binding var isPlaying: Bool     // %%%

    private var myUrl : URL?   { Bundle.main.url(forResource: "myVid", withExtension: "mp4") }

    func makeUIView(context: Context) -> PlayerView {
        PlayerUIView(frame: .zero , url  : myUrl)
    }

    // ### This one weird trick makes OS call updateUIView when view is disappearing.
    class DummyClass { } ; let x = DummyClass()

    func updateUIView(_ v: PlayerView, context: UIViewRepresentableContext<PlayerView>) {
        print("> updateUIView()")
        print("  router.isPlayingAV = \(router.isPlayingAV)")
        // print("  isPlaying = \(isPlaying)") // %%%

        // This does work. But *only* with the Dummy code ### included.
        // See also +++ comment in HomeView
        if router.isPlayingAV  { v.player?.pause() }
        else                   { v.player?.play() }

        // This logic looks reversed, but is correct.
        // If it's the other way around, vid never plays. Try it!
        //   if isPlaying { v?.player?.play()   }   // %%%
        //   else         { v?.player?.pause()  }   // %%%

        print("< updateUIView()")
    }
}

And the wrappED UIView:

class PlayerUIView: UIView {
    private let playerLayer = AVPlayerLayer()
    var player: AVPlayer?

    init(frame: CGRect, url: URL?) {
        super.init(frame: frame)
        guard let u = url else { return }

        self.player = AVPlayer(url: u)
        self.playerLayer.player = player
        self.layer.addSublayer(playerLayer)
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        playerLayer.frame = bounds
    }

    required init?(coder: NSCoder) { fatalError("not implemented") }
}

And of course the view router, based on the Blckbirds example

class ViewRouter : ObservableObject {
    let objectWillChange = PassthroughSubject<ViewRouter, Never>()

    enum Page { case home, movie }

    var page = Page.home { didSet { objectWillChange.send(self) } }

    // Claim: App will never play more than one vid at a time.
    var isPlayingAV = false  // No didSet necessary.
}

Console Logs

Console log 1 (playing stops as desired)

> updateUIView()                // First call
  router.isPlayingAV = false    // Vid is not playing => play it.
< updateUIView()
> onAppear()
< onAppear()
> updateUIView()                // Second call
  router.isPlayingAV = true     // Vid is playing => pause it.
< updateUIView()
> onDisappear()                 // After the fact, we clear
< onDisappear()                 // the isPlayingAV flag.

Console log 2 (weird trick disabled; playing continues)

> updateUIView()                // First call
  router.isPlayingAV = false
< updateUIView()
> onAppear()
< onAppear()
                                // No second call.
> onDisappear()
< onDisappear()

Console log 3 (attempt to use state & binding; playing continues)

> updateUIView()
  isPlaying = false
< updateUIView()
> onAppear()
< onAppear()
> updateUIView()
  isPlaying = true
< updateUIView()
> updateUIView()
  isPlaying = true
< updateUIView()
> onDisappear()
< onDisappear()

回答1:


Well... on

}.onDisappear {
    print("> onDisappear()")
    self.router.isPlayingAV = false
    print("< onDisappear()")
}

this is called after view is removed (it is like didRemoveFromSuperview, not will...), so I don't see anything bad/wrong/unexpected in that subviews (or even it itself) is not updated (in this case updateUIView)... I would rather surprise if it would be so (why update view, which is not in view hierarchy?!).

So this

class DummyClass { } ; let x = DummyClass()

is rather some wild bug, or ... bug. Forget about it and never use such stuff in releasing products.

OK, one would now ask, how to do with this? The main issue I see here is design-originated, specifically tight-coupling of model and view in PlayerUIView and, as a result, impossibility to manage workflow. AVPlayer here is not part of view - it is model and depending on its states AVPlayerLayer draws content. Thus the solution is to tear apart those entities and manage separately: views by views, models by models.

Here is a demo of modified & simplified approach, which behaves as expected (w/o weird stuff and w/o Group/ZStack limitations), and it can be easily extended or improved (in model/viewmodel layer)

Tested with Xcode 11.2 / iOS 13.2

Complete module code (can be copy-pasted in ContentView.swift in project from template)

import SwiftUI
import Combine
import AVKit

struct MovieView: View {
    @EnvironmentObject var router: ViewRouter

    // just for demo, but can be interchangable/modifiable
    let playerModel = PlayerViewModel(url: Bundle.main.url(forResource: "myVid", withExtension: "mp4")!)

    var body: some View {
        VStack() {
            PlayerView(viewModel: playerModel)
            Button(action: { self.router.page = .home }) {
                Text("Go back Home")
            }
        }.onAppear {
            self.playerModel.player?.play() // << changes state of player, ie model
        }.onDisappear {
            self.playerModel.player?.pause() // << changes state of player, ie model
        }
    }
}

class PlayerViewModel: ObservableObject {
    @Published var player: AVPlayer? // can be changable depending on modified URL, etc.
    init(url: URL) {
        self.player = AVPlayer(url: url)
    }
}

struct PlayerView: UIViewRepresentable { // just thing wrapper, as intended
    var viewModel: PlayerViewModel

    func makeUIView(context: Context) -> PlayerUIView {
        PlayerUIView(frame: .zero , player: viewModel.player) // if needed viewModel can be passed completely
    }

    func updateUIView(_ v: PlayerUIView, context: UIViewRepresentableContext<PlayerView>) {
    }
}

class ViewRouter : ObservableObject {
    enum Page { case home, movie }

    @Published var page = Page.home // used native publisher
}

class PlayerUIView: UIView {
    private let playerLayer = AVPlayerLayer()
    var player: AVPlayer?

    init(frame: CGRect, player: AVPlayer?) { // player is a model so inject it here
        super.init(frame: frame)

        self.player = player
        self.playerLayer.player = player
        self.layer.addSublayer(playerLayer)
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        playerLayer.frame = bounds
    }

    required init?(coder: NSCoder) { fatalError("not implemented") }
}

struct ContentView: View {
    @EnvironmentObject var router: ViewRouter

    var body: some View {
        Group {
            if router.page == .home {
                Button(action: { self.router.page = .movie }) {
                    Text("Go to Movie")
                }
            } else if router.page == .movie {
                MovieView()
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}


来源:https://stackoverflow.com/questions/60626418/getting-swiftui-wrapper-of-avplayer-to-pause-when-view-disappears

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!