SwiftUI: Detect click on already selected list item - swift

I have a List view with several items. All of then serve navigation purposes and all are Text. I cannot use NavigationLinks for this app.
That said, I am monitoring the list selection through a #State variable.
#State private var selection: String?
I print its value whever the view gets updated. It normally prints the unique id of every item when selecting different items of the list.
The problem is when trying to select the same item again: in this case the view does not get updated, and I don't see the debug message in my console.
I would like to be able to press on a list item to display a view, and then be able to press on it again, despite it already being selected because I have logic I would like to run (for example: manual view refresh).
How can I detect the re-selection of an already selected list item?
For any questions, please let me know. Thank you for your help!
Update 1
This is the code I am using:
Helper list item struct:
struct NavItem {
let id: String
let text: String
}
List:
struct NavList: View {
#Binding private var selection: String?
private var listItems: [NavItem]
//note:
//selection is a #State variable declared in a parent struct and passed here.
//This is optional. I could declare it as a #state too.
init(listItems: [NavItem], selection: Binding<String?>) {
self.listItems = listItems
self._selection = selection
debugPrint(self.selection)
}
var body: some View {
List (selection: $selection) {
ForEach(listItems, id:\.id) { item in
Text(item.text)
.tag(item.id)
}
}
}
}
Note 1: I already know this problem can be approached by using the .onTapGesture modifier. This solution seems to not be optimal. I tried seeking help for it here.
Note 2: In the project, the struct that contains the list conforms to View. When that view is displayed, it has the .listStyle(SidebarListStyle()) modifier added. This might be relevant.

there are a number of ways to do what you want,
try this with .onTapGesture{..}
List (selection: $selection) {
ForEach(listItems, id: \.id) { item in
Text(item.text)
.tag(item.id)
.padding(10)
.listRowInsets(.init(top: 0,leading: 0, bottom: 0, trailing: 0))
.frame(minWidth: 111, idealWidth: .infinity, maxWidth: .infinity, alignment: .leading)
.onTapGesture {
print("----> " + item.text)
}
}
}
or you can use a Button instead of onTapGesture;
List (selection: $selection) {
ForEach(listItems, id: \.id) { item in
Button(action: { print("----> " + item.text) }) {
Text(item.text)
}
.padding(10)
.listRowInsets(.init(top: 0,leading: 0, bottom: 0, trailing: 0))
.tag(item.id)
.frame(minWidth: 111, idealWidth: .infinity, maxWidth: .infinity, alignment: .leading)
}
}
EDIT: full test code:
This is the code I used in my tests:
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
struct NavItem {
let id: String
let text: String
}
struct ContentView: View {
#State private var listItems: [NavItem] = [NavItem(id: "1", text: "1"),
NavItem(id: "2", text: "2"),
NavItem(id: "3", text: "3"),
NavItem(id: "4", text: "4")]
#State private var selection: String?
var body: some View {
List (selection: $selection) {
ForEach(listItems, id:\.id) { item in
Button(action: { print("----> " + item.text) }) {
Text(item.text)
}
.padding(10)
.listRowInsets(.init(top: 0,leading: -5, bottom: 0, trailing: -5))
.frame(minWidth: 111, idealWidth: .infinity, maxWidth: .infinity, alignment: .leading)
.tag(item.id)
.background(Color.red) // <--- last modifier
}
}
}
}

Related

Searchbar to filter contents on a list (macOS Big Sur)

I'm trying to to implement a search on a datamodel via a searchbar.
My data model is the following struct:
struct NoteItem: Codable, Hashable, Identifiable {
let id: UUID
var text: String
var date = Date()
var dateText: String {
let df = DateFormatter()
df.dateFormat = "EEEE, MMM d yyyy, h:mm a"
return df.string(from: date)
}
var tags: [String] = []
var filename: String = ""
var changed: Bool = false
}
The application iterates on each element on the data model and populates a list, with a NavigationLink that has two Text: one with the first line of a TextEditor, and another with the datamodel.dateText. Each item in the list points to another view where the TextEditor displays the full datamodel.text
The search I'm trying to add would be only on datamodel.text. The code is as follow:
struct AllNotes: View {
#EnvironmentObject private var data: DataModel
...
var body: some View {
NavigationView {
List(data.notes) { note in
NavigationLink(
destination: NoteView(note: note, text: note.text),
tag: note.id,
selection: $selectedNoteId
) {
VStack(alignment: .leading) {
Text(getTitle(noteText: note.text)).font(.body).fontWeight(.bold)
Text(note.dateText).font(.body).fontWeight(.light)
}
.padding(.vertical, 10)
}
}
.listStyle(InsetListStyle())
.frame(minWidth: 250, maxWidth: .infinity)
}
.toolbar {
ToolbarItem(placement: .automatic) {
TextField("Search...", text: $searchText)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(minWidth: 200)
}
}
}
}
The searchbar is a TextField in the toolbar.
In iOS, in the past, I've done something similar to this:
List(ListItems.filter({ searchText.isEmpty ? true : $0.name.contains(searchText) })) { item in
Text(item.name)
}
But given that the searchbar is an item in the toolbar, and that I need to filter / repopulate the list with the search from all the text in the data model, not just the list, how would I even begin to to that? How to filter it? Do I need to populate a new array? How can I access the text on the searchbar? is it $searchText in the code I posted? Do I perform data.filter?
I've ran out of ideas to try.
Thank you.
EDIT:
I have this sort of working, but note really. It messes up how the NavigationLink are displayed, it adds random white spaces before the text.
code:
var body: some View {
NavigationView {
List {
ForEach(data.notes.filter { $0.text.contains(searchText) || searchText.isEmpty }) { note in
NavigationLink(
destination: NoteView(note: note, text: note.text),
tag: note.id,
selection: $selectedNoteId
) {
VStack(alignment: .leading) {
Text(getTitle(noteText: note.text)).font(.body).fontWeight(.bold)
Text(note.dateText).font(.body).fontWeight(.light)
}
.padding(.vertical, 10)
}
}
.listStyle(InsetListStyle())
.frame(minWidth: 250, maxWidth: .infinity)
.alert(isPresented: $showAlert, content: {
alert
})
Text("Create a new note...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
Here's the answer edited to so people don't complain:
var body: some View {
NavigationView {
List(data.notes.filter { searchText.isEmpty ? true : $0.text.localizedCaseInsensitiveContains(searchText) }) { note in
NavigationLink(
destination: NoteView(note: note, text: note.text),
tag: note.id,
selection: $selectedNoteId
) {
VStack(alignment: .leading) {
Text(getTitle(noteText: note.text)).font(.body).fontWeight(.bold)
Text(note.dateText).font(.body).fontWeight(.light)
}
.padding(.vertical, 10)
}
}
.listStyle(InsetListStyle())
.frame(minWidth: 250, maxWidth: .infinity)
.alert(isPresented: $showAlert, content: {
alert
})
}

How do I fix buttons not being tappable in a ForEach loop?

I have a view which contains a button. The view with the button is created from a forEach loop. For some reason only some buttons are tappable and others are not.
The parent view contains a NavigationView, a scroll view inside the NavigationView, a lazyVStack inside of the scroll view, a forEachloop in that lazyVStack and in that for loop is the child view that contains the button.
struct ContentView: View {
let peoples:[Person] = Bundle.main.decode("data.json")
var body: some View {
let columns = [
GridItem(.flexible(minimum: 300), spacing: 10)
]
NavigationView {
ScrollView(.vertical) {
LazyVStack {
ForEach(peoples, id: \.self) { person in
PersonView(name: person.Name, age: person.Age)
}
}
.navigationTitle("A list of people")
.navigationViewStyle(DefaultNavigationViewStyle())
.padding()
}
}
}
}
The child view is bellow. I suspect the scroll view is stealing the user input, but I am not sure why or how to overcome it. Some buttons are tapeable and some are not.
struct PersonView: View {
#Environment(\.colorScheme) var colorScheme
var name: String
var age: Int
var body: some View {
VStack(alignment:.leading) {
Image("randoPerson")
.resizable()
.scaledToFill()
.frame(minWidth: nil, idealWidth: nil,
maxWidth: UIScreen.main.bounds.width, minHeight: nil,
idealHeight: nil, maxHeight: 300, alignment: .center)
.clipped()
VStack(alignment: .leading, spacing: 6) {
Text("name")
.fontWeight(.heavy)
.padding(.leading)
Text("Age \(age)")
.foregroundColor(Color.gray)
.padding([.leading,.bottom])
Button(action: { print("I was tapped") }) {
HStack {
Image(systemName: "message.fill")
.font(.title)
.foregroundColor(.white)
.padding(.leading)
Text("Message them")
.font(.subheadline)
.foregroundColor(.white)
.padding()
}
.background(Color.blue)
}
.padding()
}
.background(Color(UIColor.systemBackground).cornerRadius(15))
.shadow(color:colorScheme == .dark
? Color.white.opacity(0.2)
: Color.black.opacity(0.2),
radius: 7, x: 0, y: 2)
}
}
}
To fix the issue, you can add an id: UUID associated to a Person and iterate between the Person, inside the ForEach using their ID.
You will also noticed that I added the value as lowercases to respect the Swift convention.
struct Person {
let id: UUID // Add this value
var name: String
var age: Int
}
So here is the ContentView with the id replacing \.self:
struct ContentView: View {
let peoples: [Person] = Bundle.main.decode("data.json")
var body: some View {
NavigationView {
ScrollView(.vertical) {
LazyVStack {
ForEach(peoples, id: \.id) { person in // id replace \.self here
PersonView(name: person.name, age: person.age) // removed uppercase
}
}
.navigationTitle("A list of people")
.navigationViewStyle(DefaultNavigationViewStyle())
.padding()
}
}
}
}

How do I handle selection in NavigationView on macOS correctly?

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
}
}
}
}

How to make a button clickable to another view controller only with textfield

I have this scenario that if the user clicks the button then another view controller will popuped which includes only a textView where the user can write something inside.
You can use the the .sheet modifier to display a modal / popup like view in SwiftUI.
struct ContentDetail: View {
struct Item {
let uuid = UUID()
let value: String
}
#State private var items = [Item]()
#State private var show_modal = false
var lectureName:String
var body: some View {
ZStack {
VStack {
Spacer()
HStack {
Spacer()
Button(action: {
self.show_modal.toggle()
}, label: {
Text("✏️")
.font(.system(.largeTitle))
.frame(width: 77, height: 70)
.foregroundColor(Color.white)
.padding(.bottom, 7)
})
.background(Color.blue)
.cornerRadius(38.5)
.padding()
.shadow(color: Color.black.opacity(0.3),
radius: 5,
x: 3,
y: 3)
}
}
}
.sheet(isPresented: self.$show_modal) {
CustomModalView()
}
}
}
struct CustomModalView: View {
#State private var text = ""
var body: some View {
TextField("test", text: $text)
.padding(5)
.textFieldStyle(RoundedBorderTextFieldStyle())
.font(.system(size: 60, design: .default))
.multilineTextAlignment(.center)
}
}
You can read more about it here:
https://blog.appsbymw.com/posts/how-to-present-and-dismiss-a-modal-in-swiftui-155c/

how to put the array values into a list of buttons that opens new views based what is clicked

as you can see the HStack is very redundant.
I want to have an array of some type that contains both the image and the text
But I do not know how to put that array into the list, more importantly I do not know how to recognize which element in the array was clicked and open a new view based on what is clicked
struct ContentView: View {
var body: some View {
ZStack {
NavigationView {
List {
// as you can see the HStack is very redundant.
// I want to have an array of some type that contains both the image and the text
// But I do not know how to put that array into the list, more importantly I do not know how to recognize which element in the array was clicked and open a new view based on what is clicked
HStack {
Image("someImage")
.resizable()
.frame(width: 50, height: 50, alignment: .leading)
.clipShape(Circle())
NavigationLink (destination: SomeView()) {
Text("SomeText").foregroundColor(.gray)
.bold()
}
}
HStack {
Image("Image2")
.resizable()
.clipShape(Circle())
.frame(width: 50, height: 50, alignment: .leading)
NavigationLink(destination: image2()) {
Text("text2").foregroundColor(.gray)
.bold()
}
}
HStack {
Image("image3")
.resizable()
.clipShape(Circle())
.frame(width: 50, height: 50, alignment: .leading)
NavigationLink(destination: view3()) {
Text("view3").foregroundColor(.gray)
.bold()
}
}
}.navigationBarTitle("list")
}
}
}
}
You will need a 3 different arrays or a Model, I'll demonstrate it with the first approach with arrays since your question was specific about arrays.
import SwiftUI
struct ContentView: View {
let views: [AnyView] = [AnyView(SomeView()), AnyView(Image2()), AnyView(View3())]
var body: some View {
NavigationView {
List {
ForEach(0...2, id: \.self) { index in
NavigationLink (destination: self.views[index]) {
ListRowView(index: index)
}
}
}.navigationBarTitle("List")
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
#endif
struct ListRowView: View {
var index: Int
let imageNames: [String] = ["cart","star","book"]
let textList: [String] = ["SomeText","text2","view3"]
var body: some View {
HStack {
Image(systemName: self.imageNames[index])
.resizable()
.clipShape(Circle())
.frame(width: 50, height: 50, alignment: .leading)
Text(self.textList[index])
.foregroundColor(.gray)
.bold()
}
}
}
struct SomeView: View {
var body: some View {
Text("SomeView")
.foregroundColor(.gray)
.bold()
}
}
struct Image2: View {
var body: some View {
Text("Image2")
.foregroundColor(.gray)
.bold()
}
}
struct View3: View {
var body: some View {
Text("View3")
.foregroundColor(.gray)
.bold()
}
}
The first array is representing your destination views with its type as AnyView, the another two arrays are a regular String arrays representing your image names and the text. I used systemName images for demonstration purposes only and you can use your own image names from the assets.
I hope it is what you are looking for, and if the answer worked for you please accept it as an answer. Also do not hesitate to engage in comments!
you can do it like this:
public struct ImageTextView<Destination> : Hashable where Destination: View {
public static func == (lhs: ImageTextView<Destination>, rhs: ImageTextView<Destination>) -> Bool {
return lhs.uuid == rhs.uuid
}
public var hashValue: Int {
return uuid.hashValue
}
var uuid = UUID().uuidString
var image: Image
var text : String
var destination: Destination
init(image: Image, text: String, destination: Destination) {
self.image = image
self.text = text
self.destination = destination
}
}
struct SomeView: View {
var body: some View {
Text ("navigation target")
}
}
let rows = [
ImageTextView(image: Image("someImage"), text: "SomeText", destination: SomeView())
// ImageTextView(image: Image("image2"), text: "text2", destination: SomeView())
]
struct ContentView: View {
var body: some View {
ZStack {
NavigationView {
List {
// as you can see the HStack is very redundant.
// I want to have an array of some type that contains both the image and the text
// But I do not know how to put that array into the list, more importantly I do not know how to recognize which element in the array was clicked and open a new view based on what is clicked
ForEach (rows, id: \.self) { row in
HStack {
row.image
.resizable()
.frame(width: 50, height: 50, alignment: .leading)
.clipShape(Circle())
NavigationLink (destination: SomeView()) {
Text(row.text).foregroundColor(.gray)
.bold()
}
}
}
}.navigationBarTitle("list")
}
}
}
}