Search code examples
iosswiftswiftuiswiftui-list

SwiftUI List redraw the content of a presented sheet


I'm having an issue with SwiftUI with a presented .sheet when a List content change. Let me explain in detail the situation.

I provided a sample example on Github that you can clone so you can easily reproduce the bug.

Context


I have only 2 views :

  • PlaylistCreationView
  • SearchView

Each of them have respectively their view models called PlaylistCreationViewModel and SearchViewModel.

The PlaylistCreationView has a Add Songs button witch will present the SearchView has a sheet. When the user touch a song in the search results, the SearchView sends a onAddSong completion handler that will notify the PlaylistCreationView that a song has been added.

Here a simple schema of the situation:

Situation

Here is also some screenshots of the views:

PlaylistCreationView

PlaylistCreationView

SearchView

SearchView

Problem

When the user touch a song in the SearchView, the SearchView content is redrawn. I mean the view is recreated. The view appears has if it have been loaded for the first time again. Just like this:

Blank Search

To simplify the problem, here is another schema of the situation:

Problem Schema

Here is what I can observe just after touching a song:

enter image description here

As you can see, the song has been added to the playlist correctly, but the SearchView is now blank because it has been recreated.

What I can't explain, is why the change of a list in the PlaylistCreationViewModel leads to redrawing the presented sheet.

How to reproduce the issue


I provided a sample example on Github that you can clone. To reproduce the issue:

  1. Clone the project and run it on any iPhone simulator.
  2. Touch "Add song".
  3. Type something on the search bar at the top of the search view.
  4. Select a song. Your search should have disappeared because the view has been rebuilded.

Code


PlaylistCreationViewModel

final class PlaylistCreationViewModel: ObservableObject {
    
    @Published var sheetType: SheetType?
    @Published var songs: [String] = []
    
}

extension PlaylistCreationViewModel {
    
    enum SheetType: String, Identifiable {
        
        case search
        
        var id: String {
            rawValue
        }
        
    }
    
}

PlaylistCreationView

struct PlaylistCreationView: View {
    
    @ObservedObject var viewModel: PlaylistCreationViewModel
    
    var body: some View {
        NavigationView {
            List {
                ForEach(viewModel.songs, id: \.self) { song in
                    Text(song)
                }
                Button("Add Song") {
                    viewModel.sheetType = .search
                }
                .foregroundColor(.accentColor)
            }
            .navigationTitle("Playlist")
        }
        .sheet(item: $viewModel.sheetType) { _ in
            sheetView
        }
    }
    
    private var sheetView: some View {
        let addingMode: SearchViewModel.AddingMode = .playlist { addedSong in
            // This line leads SwiftUI to rebuild the SearchView
            viewModel.songs.append(addedSong)
        }
        let sheetViewModel: SearchViewModel = SearchViewModel(addingMode: addingMode)
        return NavigationView {
            SearchView(viewModel: sheetViewModel)
                .navigationTitle("Search")
        }
    }
    
}

SearchViewModel

final class SearchViewModel: ObservableObject {
    
    @Published var search: String = ""
    let songs: [String] = ["Within", "One more time", "Veridis quo"]
    let addingMode: AddingMode
    
    init(addingMode: AddingMode) {
        self.addingMode = addingMode
    }
    
}

extension SearchViewModel {
    
    enum AddingMode {
        
        case playlist(onAddSong: (_ song: String) -> Void)
        
    }
    
}

SearchView

struct SearchView: View {
    
    @ObservedObject var viewModel: SearchViewModel
    
    var body: some View {
        VStack {
            TextField("Search", text: $viewModel.search)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding()
            List(viewModel.songs, id: \.self) { song in
                Button(song) {
                    switch viewModel.addingMode {
                    case .playlist(onAddSong: let onAddSong):
                        onAddSong(song)
                    }
                }
            }
        }
    }
    
}

I'm pretty sure it is some SwiftUI stuff that I'm missing. But I can't get it. I didn't encountered such a case before. I couldn't find anything on the web.

Any help or advice would be really appreciated. Thanks in advance to anyone who'll take the time to read and understand the problem. I hope I've described the problem well enough.


Solution

  • The Problem

    When you tap on Songs in SearchView you are invoking a closure block, that is defined inside Sheet view. The code below-:

    private var sheetView: some View {
        let addingMode: SearchViewModel.AddingMode = .playlist { addedSong in
            // This line leads SwiftUI to rebuild the SearchView
            viewModel.songs.append(addedSong)
        }
        let sheetViewModel: SearchViewModel = SearchViewModel(addingMode: addingMode)
        return NavigationView {
            SearchView(viewModel: sheetViewModel)
                .navigationTitle("Search")
        }
    }
    

    The goal of this closure is to add selected song in Songs array, present in PlaylistCreationViewModel class. Below is that class-:

    final class PlaylistCreationViewModel: ObservableObject {
        
        @Published var sheetType: SheetType?
        @Published var songs: [String] = []
        
    }
    

    As you can see this class is ObservableObject , and your songs array is @Published property, as soon as you add a song to this array, any view that is using this model and marked with @ObservedObject will refresh its body .In your case that view is below-:

    struct PlaylistCreationView: View {
        
        @ObservedObject var viewModel: PlaylistCreationViewModel
        
        var body: some View {
            NavigationView {
                List {
                    ForEach(viewModel.songs, id: \.self) { song in
                        Text(song)
                    }
                    Button("Add Song") {
                        viewModel.sheetType = .search
                    }
                    .foregroundColor(.accentColor)
                }
                .navigationTitle("Playlist")
            }
            .sheet(item: $viewModel.sheetType) { _ in
                sheetView
            }
        }
        
        private var sheetView: some View {
            let addingMode: SearchViewModel.AddingMode = .playlist { addedSong in
                // This line leads SwiftUI to rebuild the SearchView
                viewModel.songs.append(addedSong)
            }
            let sheetViewModel: SearchViewModel = SearchViewModel(addingMode: addingMode)
            return NavigationView {
                SearchView(viewModel: sheetViewModel)
                    .navigationTitle("Search")
            }
        }
    

    Now when this view gets refreshed after adding a song it will also reload sheetView view, why? because your sheet is reading its state from viewModel.sheetType whose values is still set to .search

    Now, when sheet is called, it also create SearchViewModel objects again, and give it to SearchView, So you are getting complete new Object with empty search string. Below is that model class-:

    final class SearchViewModel: ObservableObject {
        
        @Published var search: String = ""
        let songs: [String] = ["Within", "One more time", "Veridis quo"]
        let addingMode: AddingMode
        
        init(addingMode: AddingMode) {
            self.addingMode = addingMode
        }
        
    }
    

    Solution-:

    You can move addingMode and sheetViewModel property outside of sheetView, and put them inside PlaylistCreationView just above body property. This way when sheet is refreshed your object wouldn’t be instantiated from scratch every time.

    Working Code-:

    mport SwiftUI
    
    struct PlaylistCreationView: View {
        
        @ObservedObject var viewModel: PlaylistCreationViewModel
        var sheetViewModel: SearchViewModel
        
        init(viewModel:PlaylistCreationViewModel) {
            _viewModel = ObservedObject(wrappedValue: viewModel)
             sheetViewModel = SearchViewModel(addingMode: .playlist(onAddSong: {  addSong in
                viewModel.songs.append(addSong)
            }))
            
        }
        
        var body: some View {
            NavigationView {
                List {
                    ForEach(viewModel.songs, id: \.self) { song in
                        Text(song)
                    }
                    Button("Add Song") {
                        viewModel.sheetType = .search
                    }
                    .foregroundColor(.accentColor)
                }
                .navigationTitle("Playlist")
            }
            .sheet(item: $viewModel.sheetType) { _ in
                sheetView
            }
        }
        
        private var sheetView: some View {
             NavigationView {
                SearchView(model: sheetViewModel)
                    .navigationTitle("Search")
            }
        }
        
    }
    

    SearchView-:

    import SwiftUI
    
    struct SearchView: View {
        
        @ObservedObject var viewModel: SearchViewModel
        
        init(model:SearchViewModel) {
            _viewModel = ObservedObject(wrappedValue: model)
        }
        
        var body: some View {
            VStack {
                TextField("Search", text: $viewModel.search)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                    .padding()
                List(viewModel.songs, id: \.self) { song in
                    Button(song) {
                        switch viewModel.addingMode {
                        case .playlist(onAddSong: let onAddSong):
                            onAddSong(song)
                        }
                    }
                }
            }
        }
        
    }