Environment: Xcode 12.0 beta (12A6159)
Suppose I want to make a video app with a "Recently Watched" history, and the history is shown as a List
with an @ObservedObject
of the hisotry to be updated when the history is changed.
I tap a cell of the List
, and a NavigationLink
pushes a view of the video. When I start playing the video, the order of items in the history is changed because the video I am watching should be the latest one.
It makes the List
re-rendered because the observed hitory object was changed. As the result of re-rendering, the cells are swapped, the NavigationLink
is destroyed, and the view of the video is popped automatically.
This is the problem I struggle with. How can I implement such a "Recently Watched" hisotry with SwiftUI. It seems good if re-rendering of parent views are delayed until they appear again, or NavigationLink
s are kept as long as their cells are alive even when the list is changed.
The code to reproduce the problem is below.
- Select "A" and press the
▶️ button. - Back to the top and play "B" and "C" in the same way.
- Press the "Show History" button in the top page.
- Select "A" in the history and play it. It makes the view popped.
import SwiftUI
import Combine
@main
struct NavigationPopApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
let videos: [Video] = ["A", "B", "C"].map { Video(id: $0) }
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
// Videos
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(videos) { video in
NavigationLink(destination: VideoView(video: video)) {
// Video
ZStack {
Rectangle()
.foregroundColor(Color(UIColor.systemGray6))
.frame(width: 160, height: 90)
Text(video.id)
.font(.headline)
}
}
.buttonStyle(PlainButtonStyle())
}
}
.padding()
}
// Link to the history
NavigationLink(destination: HistoryView()) { Text("Show History") }
Spacer()
}
.navigationTitle("Videos")
}
}
}
struct HistoryView: View {
@ObservedObject var history: History = .shared
let dateFormatter: DateFormatter = {
let value = DateFormatter()
value.dateFormat = "yyyy-MM-dd HH:mm:ss"
return value
}()
var body: some View {
List(history.records.reversed()) { record in
// This `NavigationLink` is destroyed when a video is played
NavigationLink(destination: VideoView(
video: videos.first(where: { $0.id == record.id })!
)) {
VStack(alignment: .leading) {
Text("\(record.id)")
.font(.headline)
Text("\(self.dateFormatter.string(from: record.lastPlayed))")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
.navigationTitle("History")
}
}
struct VideoView: View {
@State var isPlaying: Bool = false
let video: Video
var body: some View {
VStack {
ZStack {
Rectangle()
.foregroundColor(.init(isPlaying ? UIColor.systemGray4 : UIColor.systemGray6))
.aspectRatio(16.0 / 9.0, contentMode: .fit)
Text(video.id)
.font(.largeTitle)
}
Button {
isPlaying.toggle()
if isPlaying {
// This destroy the `NavigationLink`
History.shared.record(for: self.video.id)
}
} label: {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.foregroundColor(.white)
.frame(width: 200, height: 40, alignment: .center)
.background(Color.accentColor)
.cornerRadius(4)
}
.buttonStyle(PlainButtonStyle())
.padding()
Spacer()
}
.navigationTitle(video.id)
}
}
struct Video: Identifiable {
let id: String
}
final class History: ObservableObject {
private(set) var records: [Record] = []
private let subject: PassthroughSubject<Void, Never> = .init()
var objectWillChange: AnyPublisher<Void, Never> { subject.eraseToAnyPublisher() }
func record(for id: Video.ID) {
records.removeAll(where: { $0.id == id })
records.append(Record(id: id, lastPlayed: Date()))
subject.send(())
}
static let shared: History = .init()
struct Record: Identifiable {
let id: Video.ID
let lastPlayed: Date
}
}