在 SwiftUI 中使用滑块创建音频播放器单元格

Posted

技术标签:

【中文标题】在 SwiftUI 中使用滑块创建音频播放器单元格【英文标题】:Create Audio Player cell with slider in SwiftUI 【发布时间】:2021-12-27 17:44:30 【问题描述】:

我正在尝试创建一个类似于 WhatsApp 中显示的音频消息视图的视图。

播放器代码。

struct AudioPlayerControlsView: View 
    private enum PlaybackState: Int 
        case waitingForSelection
        case buffering
        case playing
    
    
    let player: AVPlayer
    let timeObserver: PlayerTimeObserver
    let durationObserver: PlayerDurationObserver
    let itemObserver: PlayerItemObserver
    @State private var currentTime: TimeInterval = 0
    @State private var currentDuration: TimeInterval = 0
    @State private var state = PlaybackState.waitingForSelection
    
    var body: some View 
        VStack 
            if state == .waitingForSelection 
                Text("Select a song below")
             else if state == .buffering 
                Text("Buffering...")
             else 
                Text("Great choice!")
            
            
            Slider(value: $currentTime,
                   in: 0...currentDuration,
                   onEditingChanged: sliderEditingChanged,
                   minimumValueLabel: Text("\(Utility.formatSecondsToHMS(currentTime))"),
                   maximumValueLabel: Text("\(Utility.formatSecondsToHMS(currentDuration))")) 
                    // I have no idea in what scenario this View is shown...
                    Text("seek/progress slider")
            
            .disabled(state != .playing)
        
        .padding()
        // Listen out for the time observer publishing changes to the player's time
        .onReceive(timeObserver.publisher)  time in
            // Update the local var
            self.currentTime = time
            // And flag that we've started playback
            if time > 0 
                self.state = .playing
            
        
        // Listen out for the duration observer publishing changes to the player's item duration
        .onReceive(durationObserver.publisher)  duration in
            // Update the local var
            self.currentDuration = duration
        
        // Listen out for the item observer publishing a change to whether the player has an item
        .onReceive(itemObserver.publisher)  hasItem in
            self.state = hasItem ? .buffering : .waitingForSelection
            self.currentTime = 0
            self.currentDuration = 0
        
        // TODO the below could replace the above but causes a crash
//        // Listen out for the player's item changing
//        .onReceive(player.publisher(for: \.currentItem))  item in
//            self.state = item != nil ? .buffering : .waitingForSelection
//            self.currentTime = 0
//            self.currentDuration = 0
//        
    
    
    // MARK: Private functions
    private func sliderEditingChanged(editingStarted: Bool) 
        if editingStarted 
            // Tell the PlayerTimeObserver to stop publishing updates while the user is interacting
            // with the slider (otherwise it would keep jumping from where they've moved it to, back
            // to where the player is currently at)
            timeObserver.pause(true)
        
        else 
            // Editing finished, start the seek
            state = .buffering
            let targetTime = CMTime(seconds: currentTime,
                                    preferredTimescale: 600)
            player.seek(to: targetTime)  _ in
                // Now the (async) seek is completed, resume normal operation
                self.timeObserver.pause(false)
                self.state = .playing
            
        
    

现在我正在尝试将它添加到 SwiftUI 中的列表单元格中。

struct Audiomessage : Identifiable 
    var id: UUID
    var url : String
    var isPlaying : Bool


struct ChatView : View 
    let player = AVPlayer()
    private let items = [ Audiomessage(id: UUID(), url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", isPlaying: false),Audiomessage(id: UUID(), url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3", isPlaying: false)]
    
    
    var body: some View 
        VStack
            LazyVStack 
                ForEach(items)  reason in
                    AudioPlayerControlsView(player: player,
                                            timeObserver: PlayerTimeObserver(player: player),
                                            durationObserver: PlayerDurationObserver(player: player),
                                            itemObserver: PlayerItemObserver(player: player)).onTapGesture 
                        guard let url = URL(string: reason.url) else 
                            return
                        
                        let playerItem = AVPlayerItem(url: url)
                        self.player.replaceCurrentItem(with: playerItem)
                        self.player.play()

                    

                
            
            .padding(.bottom,37)
            .padding()
        
        .padding(.horizontal,10)
    

输出如下:

两者都开始在 Tap 上播放。我想一次播放一首歌。如何在 SwiftUI 中实现这一点?

【问题讨论】:

你拥有的不是minimal reproducible example——因为缺少类型,它不会编译。通常,您希望在父视图中存储一个状态是否有音频播放并将该状态(通过EnvironmentObjectBinding 或类似的东西)传递给子视图,以便子视图可以停止以前的玩家,如果他们想开始。一般来说,我可能只保留 one AVPlayer 并从子视图中进行控制。 也许这太简单了,但你为什么不打电话给stop(),然后再通过play() 开始。 developer.apple.com/documentation/avfaudio/avaudioplayer/… 然后所有当前的声音将停止并播放新的声音。 ``` .stop() .play() ``` 【参考方案1】:

您最终分配了多个AVPlayers,因为ContentView 结构经常重新创建(所有SwiftUI 视图都是这种情况),因此每次在此代码中发生时都会分配一个新的AVPlayer

struct ChatView : View 
    let player = AVPlayer()

在 ChatView 之外分配一次 AVPlayer 并将其传入,因此只有一个 AVPlayer。

甚至像这样简单的事情:

let player = AVPlayer()

struct ContentView: View 
    private let items = [ Audiomessage(id: UUID(), url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", isPlaying: false),
                          Audiomessage(id: UUID(), url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3", isPlaying: false)]

var body: some View  
   // ... rest of your code here

在我的测试中有效。


这是我的测试的完整代码(我删除了您未提供的类,并注释掉了一些用于更新值的代码,因为这需要您未提供的类)

在模拟器中,它会根据您的需要播放音乐并在曲目之间切换。

//
//  AudioPlayerControlsView.swift
//  TestForSOQuestion
//
//

import SwiftUI
import AVKit

enum Utility 
  static func formatSecondsToHMS(_ seconds: TimeInterval) -> String 
    let secondsInt:Int = Int(seconds.rounded(.towardZero))
    
    let dh: Int = (secondsInt/3600)
    let dm: Int = (secondsInt - (dh*3600))/60
    let ds: Int = secondsInt - (dh*3600) - (dm*60)
    
    let hs = "\(dh > 0 ? "\(dh):" : "")"
    let ms = "\(dm<10 ? "0" : "")\(dm):"
    let s = "\(ds<10 ? "0" : "")\(ds)"
    
    return hs + ms + s
  


struct AudioPlayerControlsView: View 
  private enum PlaybackState: Int 
    case waitingForSelection
    case buffering
    case playing
  
  
  let player: AVPlayer
  let timeObserver: PlayerTimeObserver
  let durationObserver: PlayerDurationObserver
  let itemObserver: PlayerItemObserver
  @State private var currentTime: TimeInterval = 0
  @State private var currentDuration: TimeInterval = 0
  @State private var state = PlaybackState.waitingForSelection
  
  var body: some View 
    VStack 
      if state == .waitingForSelection 
        Text("Select a song below")
       else if state == .buffering 
        Text("Buffering...")
       else 
        Text("Great choice!")
      
      
      Slider(value: $currentTime,
           in: 0...currentDuration,
           onEditingChanged: sliderEditingChanged,
           minimumValueLabel: Text("\(Utility.formatSecondsToHMS(currentTime))"),
           maximumValueLabel: Text("\(Utility.formatSecondsToHMS(currentDuration))")) 
          // I have no idea in what scenario this View is shown...
          Text("seek/progress slider")
      
      .disabled(state != .playing)
    
    .padding()
  
  
  // MARK: Private functions
  private func sliderEditingChanged(editingStarted: Bool) 
    if editingStarted 
      // Tell the PlayerTimeObserver to stop publishing updates while the user is interacting
      // with the slider (otherwise it would keep jumping from where they've moved it to, back
      // to where the player is currently at)
      timeObserver.pause(true)
    
    else 
      // Editing finished, start the seek
      state = .buffering
      let targetTime = CMTime(seconds: currentTime,
                  preferredTimescale: 600)
      player.seek(to: targetTime)  _ in
        // Now the (async) seek is completed, resume normal operation
        self.timeObserver.pause(false)
        self.state = .playing
      
    
  


struct AudioPlayerControlsView_Previews: PreviewProvider 
  static let previewPlayer: AVPlayer = AVPlayer()
  
    static var previews: some View 
    AudioPlayerControlsView(player: previewPlayer,
                timeObserver: PlayerTimeObserver(player: player),
                durationObserver: PlayerDurationObserver(player: player),
                itemObserver: PlayerItemObserver(player: player))
    


还有 ContentView.swift

//
//  ContentView.swift
//  TestForSOQuestion
//

import SwiftUI
import AVKit
import Combine


class PlayerTimeObserver: ObservableObject 
  let player: AVPlayer
  init(player: AVPlayer) 
    self.player = player
  
  
  func pause(_ flag: Bool) 


class PlayerDurationObserver 
  let player: AVPlayer
  
  init(player: AVPlayer) 
    self.player = player
  


class PlayerItemObserver 
  let player: AVPlayer
  init(player: AVPlayer) 
    self.player = player
  



struct Audiomessage : Identifiable 
  var id: UUID
  var url : String
  var isPlaying : Bool


let player = AVPlayer()

struct ContentView: View 
  private let items = [ Audiomessage(id: UUID(), url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", isPlaying: false),
              Audiomessage(id: UUID(), url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3", isPlaying: false)]
  
  var body: some View 
    VStack
      LazyVStack 
        ForEach(items)  reason in
          AudioPlayerControlsView(player: player,
                      timeObserver: PlayerTimeObserver(player: player),
                      durationObserver: PlayerDurationObserver(player: player),
                      itemObserver: PlayerItemObserver(player: player)).onTapGesture 
            guard let url = URL(string: reason.url) else 
              return
            
            let playerItem = AVPlayerItem(url: url)
            player.replaceCurrentItem(with: playerItem)
            player.play()
          

        
      
      .padding(.bottom,37)
      .padding()
    
    .padding(.horizontal,10)
  


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

创建一个新的 ios 应用项目,将模板的 ContentView 替换为上面的实现,并添加一个新文件 AudioPlayerControlsView.swift 并将上面提供的内容放入其中。运行并单击其中一行,然后单击另一行,注意音乐播放并从一个切换到另一个。

最初点击时似乎有点延迟,但我认为这是音乐网址的加载。您将不得不弄清楚一些 UX 以明确收到点击操作并且用户需要等待。一旦你点击了每一行并给它时间加载数据似乎加载得更快或什么的。

干杯。

【讨论】:

以上是关于在 SwiftUI 中使用滑块创建音频播放器单元格的主要内容,如果未能解决你的问题,请参考以下文章

swift中的音频播放器没有获得音量和音高的价值

点击单元格按钮时如何更新单元格滑块的值?

在目标 c 中按住 tableview 单元格上的按钮状态单击

SwiftUI - 带有可点击按钮的单元格(在表单内)

使用 UITableView 的音频播放器

Swift:如何在动态 UITableView 的自定义单元格中获取滑块的值?