How do I handle selection in NavigationView on macOS correctly? - swift

I want to build a trivial macOS application with a sidebar and some contents according to the selection in the sidebar.
I have a MainView which contains a NavigationView with a SidebarListStyle. It contains a List with some NavigationLinks. These have a binding for a selection.
I would expect the following things to work:
When I start my application the value of the selection is ignored. Neither is there a highlight for the item in the sidebar nor a content in the detail pane.
When I manually select an item in the sidebar it should be possible to navigate via up/down arrow keys between the items. This does not work as the selection / highlight disappears.
When I update the value of the selection-binding it should highlight the item in the list which doesn't happen.
Here is my example implementation:
enum DetailContent: Int, CaseIterable {
case first, second, third
}
extension DetailContent: Identifiable {
var id: Int { rawValue }
}
class NavigationRouter: ObservableObject {
#Published var selection: DetailContent?
}
struct DetailView: View {
#State var content: DetailContent
#EnvironmentObject var navigationRouter: NavigationRouter
var body: some View {
VStack {
Text("\(content.rawValue)")
Button(action: { self.navigationRouter.selection = DetailContent.allCases.randomElement()!}) {
Text("Take me anywhere")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct MainView: View {
#ObservedObject var navigationRouter = NavigationRouter()
#State var detailContent: DetailContent? = .first
var body: some View {
NavigationView {
List {
Section(header: Text("Section")) {
ForEach(DetailContent.allCases) { item in
NavigationLink(
destination: DetailView(content: item),
tag: item,
selection: self.$detailContent,
label: { Text("\(item.rawValue)") }
)
}
}
}
.frame(minWidth: 250, maxWidth: 350)
}
.environmentObject(navigationRouter)
.listStyle(SidebarListStyle())
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onReceive(navigationRouter.$selection) { output in
self.detailContent = output
}
}
}
The EnvironmentObject is used to propagate the change from inside the DetailView. If there's a better solution I'm very happy to hear about it.
So the question remains:
What am I doing wrong that this happens?
I had some hope that with Xcode 11.5 Beta 1 this would go away but that's not the case.

After finding the tutorial from Apple it became clear that you don't use NavigiationLink on macOS. Instead you bind the list and add two views to NavigationView.
With these updates to MainView and DetailView my example works perfectly:
struct DetailView: View {
#Binding var content: DetailContent?
#EnvironmentObject var navigationRouter: NavigationRouter
var body: some View {
VStack {
Text("\(content?.rawValue ?? -1)")
Button(action: { self.navigationRouter.selection = DetailContent.allCases.randomElement()!}) {
Text("Take me anywhere")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct MainView: View {
#ObservedObject var navigationRouter = NavigationRouter()
#State var detailContent: DetailContent?
var body: some View {
NavigationView {
List(selection: $detailContent) {
Section(header: Text("Section")) {
ForEach(DetailContent.allCases) { item in
Text("\(item.rawValue)")
.tag(item)
}
}
}
.frame(minWidth: 250, maxWidth: 350)
.listStyle(SidebarListStyle())
DetailView(content: $detailContent)
}
.environmentObject(navigationRouter)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onReceive(navigationRouter.$selection) { output in
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) {
self.detailContent = output
}
}
}
}

Related

SwiftUI how to prevent view from automatically exiting when parent views list filters the item out

Hi I am fairly new to SwiftUI and I am building an application that has a list of items that, when clicked, show details about that item. There is an option to favorite these items and with a segmented picker at the top it filters the items to 'All items' or 'Favorites'. The problem is when the picker is on favorites, the user selects an item, and unfavorites the item in it's detail view, the user is automatically kicked out of it's detail view. Is there a simple way to prevent this?
Here is the code in the ContentView
import SwiftUI
struct ContentView: View {
#EnvironmentObject var vm : ViewModel
#State private var BiasStruct: BiasData = BiasData.allBias
#State var searchText = ""
#State var filterSearchText = ""
#State var selected = 1
var body: some View {
NavigationView{
VStack{
Picker("Hello", selection: $selected, content: {
Text("All Biases").tag(1)
Text("Favorites").tag(2)
})
.onChange(of: selected) { tag in //When selected is changed, sort the Favs list and reset search text
vm.sortFavs()
searchText = ""
filterSearchText = ""
}
.pickerStyle(SegmentedPickerStyle())
if (selected == 1){
List{
ForEach(searchText == "" ? BiasStruct.biases : BiasStruct.biases.filter({
$0.name.lowercased().contains(searchText.lowercased())
}), id: \.id){ entry in
HStack{
NavigationLink(destination: DetailView( thisBiase: $BiasStruct.biases[entry.id-1]), label: {
Text("\(entry.name)")
Image(systemName: vm.contains(entry) ? "heart.fill" : "heart")
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
.onTapGesture {
vm.toggleFav(item: entry)
}
})
}
}
}
.searchable(text: $searchText)
.navigationTitle("Biases")
.cornerRadius(15)
}else if (selected == 2){
List{
ForEach(filterSearchText == "" ? vm.filteredItems :vm.filteredItems.filter({
$0.name.lowercased().contains(filterSearchText.lowercased())
}), id: \.id){ entry in
HStack{
NavigationLink(destination: DetailView( thisBiase: $BiasStruct.biases[entry.id-1]), label: {
Text("\(entry.name)")
Image(systemName: vm.contains(entry) ? "heart.fill" : "heart")
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
.onTapGesture {
vm.toggleFav(item: entry)
}
})
}
}
}
.searchable(text: $filterSearchText)
.navigationTitle("Favorites")
.cornerRadius(15)
}
}
}
.padding(8)
}
}
This is the code in the DetailView that toggles the favorite
import SwiftUI
struct DetailView: View {
var body: some View {
Image(systemName: vm.contains(thisBiase) ? "heart.fill" : "heart")
.frame(alignment: .trailing)
.padding(10)
.background(Color(.systemGray4))
.cornerRadius(8)
.onTapGesture {
vm.toggleFav(item: thisBiase)
}
}
}
The ViewModel class...
import Foundation
import SwiftUI
#MainActor final class ViewModel: ObservableObject{
#Published var items = [Biase]()
#Published var showingFavs = false
#Published var savedItems: Set<Int> = [1, 7]
// Filter saved items
var filteredItems: [Biase] {
if showingFavs {
return items.filter { savedItems.contains($0.id) }
}
return items
}
private var BiasStruct: BiasData = BiasData.allBias
private var db = Database()
init() {
self.savedItems = db.load()
self.items = BiasStruct.biases
}
func sortFavs(){
withAnimation() {
showingFavs.toggle()
}
}
func contains(_ item: Biase) -> Bool {
savedItems.contains(item.id)
}
// Toggle saved items
func toggleFav(item: Biase) {
if contains(item) {
savedItems.remove(item.id)
} else {
savedItems.insert(item.id)
}
db.save(items: savedItems)
}
}
I really appreciate your help!
My initial thought was to have a bool var that, when true, would not actually change the favorite value until after the user left its detail view. Even if I could get that to work it's not ideal because if the user leaves the app in its detail view the favorite is not saved.
Yep that was a major problem with NavigationView. Its replacement: NavigationStack and .navigationDestination are designed to resolve it.

Get a specific id in a modal

I'm still learning on the job and my question may seem stupid.
I've got a list of movies and on the tap I want to show card of the selected movie.
So I've got my ResultsView
var results:[DiscoverResult]
#State private var resultsCount:Int = 0
#State private var isPresented:Bool = false
#EnvironmentObject private var genres:Genres
var body: some View {
ScrollView {
ForEach (results){ result in
Button(action: {
isPresented.toggle()
}, label: {
ZStack {
ZStack {
KFImage(URL (string: baseUrlForThumb + result.posterPath)).resizable().scaledToFill()
.frame( height: 150)
.mask(Rectangle().frame( height: 150))
Rectangle().foregroundColor(.clear) // Making rectangle transparent
.background(LinearGradient(gradient: Gradient(colors: [.clear, .clear, .black]), startPoint: .top, endPoint: .bottom))
}.frame( height: 150)
// Titre du film
VStack(alignment: .center) {
Spacer()
Text(result.title)
.fontWeight(.bold)
.foregroundColor(.white)
.multilineTextAlignment(.center)
// Genres du film
Text(genres.generateGenresList(genreIDS: result.genreIDS)).font(.caption).foregroundColor(.white).multilineTextAlignment(.center)
} .padding()
}.padding(.horizontal)
})
.sheet(isPresented: $isPresented, content: {
MovieView(isPresented: $isPresented, movieId: result.id)
})
.navigationTitle(result.title)
}
}
}
}
And my MovieView
import SwiftUI
struct MovieView: View {
#Binding var isPresented: Bool
var movieId:Int
var body: some View {
VStack {
Text(String(movieId))
.padding()
Button("Fermer") {
isPresented = false
}
}
}
}
But the movie card still the same even list element selected.
I think that the 'result.id' is overwrite at every loop but i don't know how to fix it.
Sorry for my english mistakes.
thank for your purpose.
Instead of using isPresented for .sheet you can use .sheet(item:, content:) and pass the whole result object
.sheet(item: $selecteditem( { result in
MovieView(item: result)
}
To make this work you need a new property (you can remove isPresented)
#State private var selectedItem: DiscoverResult?
and you need update your MovieView struct
struct MovieView: View {
let result: DiscoverResult
var body: some View {
//...
}
}
or pass only the movie id to your MovieView if you prefer that.

SwiftUI #State is updated only once when put inside NavigationView (macOS app)

Problem
I've put a simple button that increments a counter inside NavigationView, but the application appears to update #State value only once; later clicks are ignored. Switching between NavigationLinks will cause the view to update, but why it does not automatically after the counter did change?
Facts
otherView works fine after I put it outside the Navigation View.
Code
import SwiftUI
struct ContentView: View {
#State var counter = 0
var otherView: some View {
Button(action: {
counter += 1
}, label: {
Text(String(counter))
}).frame(maxWidth: .infinity, maxHeight: .infinity).padding()
}
var body: some View {
HStack {
NavigationView {
List {
NavigationLink("otherview", destination: otherView)
}.listStyle(SidebarListStyle())
}
}.frame(minWidth: 500, minHeight: 400)
}
}
Try to move the otherView into a seperate SwiftUI view like this:
struct ButtonView: View {
#State var counter = 0
var body: some View {
Button(action: {
counter += 1
}, label: {
Text(String(counter))
}).frame(maxWidth: .infinity, maxHeight: .infinity).padding()
}
}
Then in your ContentView call it like this:
struct ContentView: View {
var body: some View {
HStack {
NavigationView {
List {
NavigationLink("otherview", destination: ButtonView())
}.listStyle(SidebarListStyle())
}
}.frame(minWidth: 500, minHeight: 400)
}
}

Swiftui navigationLink macOS default/selected state

I build a macOS app in swiftui
i try to create a listview where the first item is preselected. i tried it with the 'selected' state of the navigationLink but it didn't work.
Im pretty much clueless and hope you guys can help me.
The code for creating this list view looks like this.
//personList
struct PersonList: View {
var body: some View {
NavigationView
{
List(personData) { person in
NavigationLink(destination: PersonDetail(person: person))
{
PersonRow(person: person)
}
}.frame(minWidth: 300, maxWidth: 300)
}
}
}
(Other views at the bottom)
This is the normal View when i open the app.
When i click on an item its open like this. Thats the state i want as default opening state when i render this view.
The Code for this view looks like this:
//PersonRow
struct PersonRow: View {
//variables definied
var person: Person
var body: some View {
HStack
{
person.image.resizable().frame(width:50, height:50)
.cornerRadius(25)
.padding(5)
VStack (alignment: .leading)
{
Text(person.firstName + " " + person.lastName)
.fontWeight(.bold)
.padding(5)
Text(person.nickname)
.padding(5)
}
Spacer()
}
}
}
//personDetail
struct PersonDetail: View {
var person : Person
var body: some View {
VStack
{
HStack
{
VStack
{
CircleImage(image: person.image)
Text(person.firstName + " " + person.lastName)
.font(.title)
Text("Turtle Rock")
.font(.subheadline)
}
Spacer()
Text("Subtitle")
.font(.subheadline)
}
Spacer()
}
.padding()
}
}
Thanks in advance!
working example. See how selection is initialized
import SwiftUI
struct Detail: View {
let i: Int
var body: some View {
Text("\(self.i)").font(.system(size: 150)).frame(maxWidth: .infinity)
}
}
struct ContentView: View {
#State var selection: Int?
var body: some View {
HStack(alignment: .top) {
NavigationView {
List {
ForEach(0 ..< 10) { (i) in
NavigationLink(destination: Detail(i: i), tag: i, selection: self.$selection) {
VStack {
Text("Row \(i)")
Divider()
}
}
}.onAppear {
if self.selection != nil {
self.selection = 0
}
}
}.frame(width: 100)
}
}.background(Color.init(NSColor.controlBackgroundColor))
}
}
screenshot
You can define a binding to the selected row and used a List reading this selection. You then initialise the selection to the first person in your person array.
Note that on macOS you do not use NavigationLink, instead you conditionally show the detail view with an if statement inside your NavigationView.
If person is not Identifiable you should add an id: \.self in the loop. This ressembles to:
struct PersonList: View {
#Binding var selectedPerson: Person?
var body: some View {
List(persons, id: \.self, selection: $selectedPerson) { person in // persons is an array of persons
PersonRow(person: person).tag(person)
}
}
}
Then in your main window:
struct ContentView: View {
// First cell will be highlighted and selected
#State private var selectedPerson: Person? = person[0]
var body: some View {
NavigationView {
PersonList(selectedPerson: $selectedPerson)
if selectedPerson != nil {
PersonDetail(person: person!)
}
}
}
}
Your struct person should be Hashable in order to be tagged in the list. If your type is simple enough, adding Hashable conformance should be sufficient:
struct Person: Hashable {
var name: String
// ...
}
There is a nice tutorial using the same principle here if you want a more complete example.
Thanks to this discussion, as a MacOS Beginner, I managed a very basic NavigationView with a list containing two NavigationLinks to choose between two views. I made it very basic to better understand. It might help other beginners.
At start up it will be the first view that will be displayed.
Just modify in ContentView.swift, self.selection = 0 by self.selection = 1 to start with the second view.
FirstView.swift
import SwiftUI
struct FirstView: View {
var body: some View {
Text("(1) Hello, I am the first view")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct FirstView_Previews: PreviewProvider {
static var previews: some View {
FirstView()
}
}
SecondView.swift
import SwiftUI
struct SecondView: View {
var body: some View {
Text("(2) Hello, I am the second View")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct SecondView_Previews: PreviewProvider {
static var previews: some View {
SecondView()
}
}
ContentView.swift
import SwiftUI
struct ContentView: View {
#State var selection: Int?
var body: some View {
HStack() {
NavigationView {
List () {
NavigationLink(destination: FirstView(), tag: 0, selection: self.$selection) {
Text("Click Me To Display The First View")
} // End Navigation Link
NavigationLink(destination: SecondView(), tag: 1, selection: self.$selection) {
Text("Click Me To Display The Second View")
} // End Navigation Link
} // End list
.frame(minWidth: 350, maxWidth: 350)
.onAppear {
self.selection = 0
}
} // End NavigationView
.listStyle(SidebarListStyle())
.frame(maxWidth: .infinity, maxHeight: .infinity)
} // End HStack
} // End some View
} // End ContentView
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Result:
import SwiftUI
struct User: Identifiable {
let id: Int
let name: String
}
struct ContentView: View {
#State private var users: [User] = (1...10).map { User(id: $0, name: "user \($0)")}
#State private var selection: User.ID?
var body: some View {
NavigationView {
List(users) { user in
NavigationLink(tag: user.id, selection: $selection) {
Text("\(user.name)'s DetailView")
} label: {
Text(user.name)
}
}
Text("Select one")
}
.onAppear {
if let selection = users.first?.ID {
self.selection = selection
}
}
}
}
You can use make the default selection using onAppear (see above).

SwiftUI macOS NavigationView Cannot Highlight First Entry

I'm attempting to create a master/detail view on macOS with SwiftUI. When the master/detail view first renders, I'd like it to immediately "highlight" / "navigate to" its first entry.
In other words, I'd like to immediately render the following: master/detail first row highlighted
I'm using NavigationView and NavigationLink on macOS to render the master/detail view:
struct ContentView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination: Text("detail-1").frame(maxWidth: .infinity, maxHeight: .infinity)) {
Text("link-1")
}
NavigationLink(destination: Text("detail-2").frame(maxWidth: .infinity, maxHeight: .infinity)) {
Text("link-2")
}
}
}
}
}
I've tried using both the isActive and the tag / selection options provided by NavigationLink with no luck. What might I be missing here? Is there a way to force focus on the first master/detail element using SwiftUI?
I came across this problem recently and after being stuck at the same point I found Apple's tutorial which shows that you don't use NavigationLink on macOS.
Instead you just create a NavigationView with a List and a DetailView. Then you can bind the List's selection and it works properly.
There still seems to be a bug with the highlight. The workaround is setting the selection in the next run loop after the NavigationView has appeared. :/
Here's a complete example:
enum DetailContent: Int, CaseIterable, Hashable {
case first, second, third
}
extension DetailContent: Identifiable {
var id: Int { rawValue }
}
struct DetailView: View {
#Binding var content: DetailContent?
var body: some View {
VStack {
Text("\(content?.rawValue ?? -1)")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct MainView: View {
#State var detailContent: DetailContent?
var body: some View {
NavigationView {
List(selection: $detailContent) {
Section(header: Text("Section")) {
ForEach(DetailContent.allCases) { item in
Text("\(item.rawValue)")
.tag(item)
}
}
}
.frame(minWidth: 250, maxWidth: 350)
.listStyle(SidebarListStyle())
if detailContent != nil {
DetailView(content: $detailContent)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
DispatchQueue.main.async {
self.detailContent = DetailContent.allCases.randomElement()
}
}
}
}