Skip to content

Instantly share code, notes, and snippets.

@koher

koher/README.md Secret

Last active June 27, 2020 01:26
Show Gist options
  • Save koher/2b7b6a7b172dbd0585aa754f0ef0a0a7 to your computer and use it in GitHub Desktop.
Save koher/2b7b6a7b172dbd0585aa754f0ef0a0a7 to your computer and use it in GitHub Desktop.

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 NavigationLinks are kept as long as their cells are alive even when the list is changed.

The code to reproduce the problem is below.

  1. Select "A" and press the ▶️ button.
  2. Back to the top and play "B" and "C" in the same way.
  3. Press the "Show History" button in the top page.
  4. 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
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment