结合
ScrollView
+LazyVStack
+AVPlayer
,30 行核心代码即可完成「滑到哪儿播到哪儿」的沉浸式体验。
本文参考Yutube上的一个Demo,将原视频的视频播放和暂停逻辑做了改动,利用本地视频,实现类似TikTok的视频秀效果
项目结构速览
文件 | 作用 |
---|---|
TikTokView.swift | 顶层容器,负责垂直分页滚动与当前播放状态 |
TikTokContentView.swift | 单条视频卡片,内部持有独立 AVPlayer |
LightweightVideoPlayer.swift | 极简 UIViewRepresentable ,只渲染视频层 |
Models.swift | FeedResponse 数据模型 & 本地假数据 |
Assets/ | mp4 示例视频与自定义字体 TikTokIcons.ttf |
1. TikTokView:一屏一视频的分页列表
swift
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
swift
@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
;这样切走再返回时能秒播,且无需全局单例。
播放 & 暂停的核心逻辑
swift
private func syncPlayback() {
if isActive {
player.play() // 当前页:播放
} else {
player.pause() // 其它页:暂停
}
}
.onAppear { syncPlayback() } // 首次出现
.onChange(of: currentPlayingID) { _ in syncPlayback() }
.onDisappear { // 离屏:停+复位
player.pause()
player.seek(to: .zero)
}
- 出现时 :判断自己是不是当前页,决定是否立刻
play()
。 - 全局 id 变化 :再次比对,若失去焦点则
pause()
。 - 完全离屏:保证下次出现从 0 秒开始。
整个逻辑只依赖一个
currentPlayingID
,干净、可测试,无需 KVO 监听滚动偏移。
3. LightweightVideoPlayer:极简 UIKit 封装
swift
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. 可以做得更好的地方
- 预加载下一条视频 :滑动前提前
prepare
,体验更丝滑。 - 远程流媒体 :示例用了本地
mp4
,生产可接入 HLS。 - 封面 & 首帧:未播放时显示静态封面,减少黑屏闪烁。
- 全局静音切换:结合系统音量/静音键。
- 点赞动画 :借助
MatchedGeometryEffect
或自定义CAEmitterLayer
。