SwiftUI 打造 TikTok 风格的滑动短视频播放器

355 阅读3分钟

结合 ScrollView + LazyVStack + AVPlayer,30 行核心代码即可完成「滑到哪儿播到哪儿」的沉浸式体验。


本文参考Yutube上的一个Demo,将原视频的视频播放和暂停逻辑做了改动,利用本地视频,实现类似TikTok的视频秀效果

项目结构速览

文件作用
TikTokView.swift顶层容器,负责垂直分页滚动与当前播放状态
TikTokContentView.swift单条视频卡片,内部持有独立 AVPlayer
LightweightVideoPlayer.swift极简 UIViewRepresentable,只渲染视频层
Models.swiftFeedResponse 数据模型 & 本地假数据
Assets/mp4 示例视频与自定义字体 TikTokIcons.ttf

1. TikTokView:一屏一视频的分页列表

ScrollView(showsIndicators: false) {
    LazyVStack(spacing: 0) {
        ForEach(feedData) { feed in
            TikTokContentView(feed: feed,
                              currentPlayingID: $currentPlayingID)
                .id(feed.id)                 // ✨ 绑定唯一 id
        }
    }
    .scrollTargetLayout()
}
.scrollTargetBehavior(.paging)          // ✨ 分页滚动
.scrollPosition(id: $scrollPosition)    // ✨ 捕获当前可见 id
.onChange(of: scrollPosition) { _, newID in
    currentPlayingID = newID            // 告诉子组件该谁播放
}
  • 分页滚动scrollTargetBehavior(.paging) 让每次滑动都停在整页。
  • 当前页识别scrollPosition(id:) 自动把最居中的 cell 的 id 写入 scrollPosition 绑定。
  • 播放指令下发onChange 将最新 id 存入 currentPlayingID,所有子组件都能感知。

这样就避免了手动计算可见区域,也无需监听 GeometryReader


2. TikTokContentView:每条视频自带一个 AVPlayer

@State private var player: AVPlayer
private var isActive: Bool { currentPlayingID == feed.id }

init(feed: FeedResponse,
     currentPlayingID: Binding<String?>) {
    _currentPlayingID = currentPlayingID
    let url = Bundle.main.url(forResource: feed.videoUrl,
                              withExtension: "mp4")!
    _player = State(initialValue: AVPlayer(url: url))
}
  • 初始化阶段即 为每个视频创建独立 AVPlayer,并缓存在 @State;这样切走再返回时能秒播,且无需全局单例。

播放 & 暂停的核心逻辑

private func syncPlayback() {
    if isActive {
        player.play()     // 当前页:播放
    } else {
        player.pause()    // 其它页:暂停
    }
}

.onAppear { syncPlayback() }         // 首次出现
.onChange(of: currentPlayingID) { _ in syncPlayback() }
.onDisappear {                       // 离屏:停+复位
    player.pause()
    player.seek(to: .zero)
}
  1. 出现时:判断自己是不是当前页,决定是否立刻 play()
  2. 全局 id 变化:再次比对,若失去焦点则 pause()
  3. 完全离屏:保证下次出现从 0 秒开始。

整个逻辑只依赖一个 currentPlayingID,干净、可测试,无需 KVO 监听滚动偏移。


3. LightweightVideoPlayer:极简 UIKit 封装

final class PlayerLayerView: UIView {
    override class var layerClass: AnyClass { AVPlayerLayer.self }
    var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
}

struct LightweightVideoPlayer: UIViewRepresentable {
    let player: AVPlayer
    func makeUIView(context: Context) -> PlayerLayerView {
        let v = PlayerLayerView()
        v.playerLayer.videoGravity = .resizeAspectFill   // 铺满
        v.playerLayer.player = player
        return v
    }
    func updateUIView(_ uiView: PlayerLayerView, context: Context) {}
}
  • 只有 15 行:去掉 AVPlayerViewController 冗余 UI。
  • containerRelativeFrame:在父级强制铺满,全屏显示。

4. 性能小结

优化点说明
LazyVStack只实例化可见行,减少内存
独立 AVPlayer避免频繁换源;复用同一 AVPlayer 反而要处理时许状态机
.scrollTargetBehavior(.paging)系统级分页,动画跟手且省心
onDisappear 重置释放解码管线,防止后台继续占用 GPU

5. 可以做得更好的地方

  1. 预加载下一条视频:滑动前提前 prepare,体验更丝滑。
  2. 远程流媒体:示例用了本地 mp4,生产可接入 HLS。
  3. 封面 & 首帧:未播放时显示静态封面,减少黑屏闪烁。
  4. 全局静音切换:结合系统音量/静音键。
  5. 点赞动画:借助 MatchedGeometryEffect 或自定义 CAEmitterLayer

下载 DEMO 源码

点此下载完整 SwiftUI 示例项目