I have a SwiftUI view with a search bar on iOS 15. When the search bar is activated, a search list is presented, when no search is active, a regular content view is shown.
The problem I am facing is that when I activate a navigation link from the search list, when the navigation starts to take effect, the isSearching flag is turned to false and the regular content view is shown, even though I would want to search to stay active, just like when we would have a list/table and the user would select a row: the search stays active, and when the user navigates back, the search results are still displayed.
Is there a way in SwiftUI to control how the isSearching is changed?
I put together a small sample project that demoes the problem:
import SwiftUI
struct ContentView: View {
#ObservedObject var viewModel: ContentView.ViewModel
var body: some View {
NavigationView {
VStack {
ContentViewWrapper(viewModel: viewModel)
}
.navigationTitle("Searchable")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $viewModel.searchString, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search")
.navigationViewStyle(.stack)
.edgesIgnoringSafeArea(.top)
}
}
}
// MARK: View model for the content view
extension ContentView {
class ViewModel: ObservableObject {
#Published var isShowingDestinationScreen = false
#Published var isSearching = false
#Published var searchString = ""
func buttonTapped() {
if !isShowingDestinationScreen {
isShowingDestinationScreen = true
}
}
func isSearchingHasChanged(newValue: Bool) {
if isSearching != newValue {
isSearching = newValue
}
}
}
}
// MARK: Wrapper for the content view so it can be used with the searchable API
struct ContentViewWrapper: View {
#ObservedObject var viewModel: ContentView.ViewModel
#Environment(\.isSearching) var isSearching
var body: some View {
VStack {
if viewModel.isSearching {
NavigationLink(
isActive: $viewModel.isShowingDestinationScreen,
destination: {
DestinationView()
.navigationTitle("Destination")
}, label: {
EmptyView()
}
)
SearchList() {
viewModel.buttonTapped()
}
} else {
ContentViewMenu()
}
}
.onChange(of: isSearching) { newValue in
viewModel.isSearchingHasChanged(newValue: newValue)
}
}
}
// MARK: Just three simple screens below
struct ContentViewMenu: View {
var body: some View {
Text("Content View Menu")
}
}
struct SearchList: View {
var destinationButtonTapped: () -> Void
var body: some View {
VStack {
Text("Search list")
Button("Go to destination") {
destinationButtonTapped()
}
}
}
}
struct DestinationView: View {
var body: some View {
Text("Destination")
}
}
Also here is a short video showing the behaviour: note how when the Go to destination button is tapped, the screen is updated to the content view because isSearching turns false.
Is there a way to keep isSearching true in this case?
I believe you have two options here:
Normally, when users click on a search field, we expect them to always enter something. It is not possible that someone clicks on a search field without typing anything, otherwise it's just an accident touch, so anything should not execute because of this. Your solution here is: you don't have to do anything at all. Just type anything to the search bar after you clicked on it; you can even just input a space, then your search and search result will always remain active no matter what.
If you still want your search bar to be active even though there is zero interaction or input with the search bar, you can adjust some part of your ContentViewWrapper as below(But I think it's not practical to do this because why would you want your search bar to be active without any input?):
code:
struct ContentViewWrapper: View {
#ObservedObject var viewModel: ContentView.ViewModel
#Environment(\.isSearching) var isSearching
//new code
#State var isShowing = false
var body: some View {
VStack {
//new code
if viewModel.isSearching || isShowing {
NavigationLink(
isActive: $viewModel.isShowingDestinationScreen,
destination: {
DestinationView()
.navigationTitle("Destination")
}, label: {
EmptyView()
}
)
.onAppear {
isShowing = true
}
}
//new code
if isShowing {
SearchList() {
viewModel.buttonTapped()
}
}
}
.onChange(of: isSearching) { newValue in
viewModel.isSearchingHasChanged(newValue: newValue)
}
}
}
Related
I have a search TextField within a View that is triggered to appear within a sheet on top of the ContentView.
I'm able to automatically focus this TextField when the sheet appears using #FocusState and onAppear, however, I'm finding that the sheet needs to fully appear before the TextField is focused and the on screen keyboard appears.
This feels quite slow and I've noticed in many other apps that they are able to trigger the on screen keyboard and the sheet appearing simultaneously.
Here is my code:
struct ContentView: View {
#State var showSearch = false
var body: some View {
Button {
showSearch = true
} label: {
Text("Search")
}
.sheet(isPresented: $showSearch) {
SearchView()
}
}
}
struct SearchView: View {
#State var searchTerm = ""
#FocusState private var searchFocus: Bool
var body: some View {
TextField("Search", text: $searchTerm)
.focused($searchFocus)
.onAppear() {
searchFocus = true
}
}
}
Is there a different way to do this that will make the keyboard appear as the sheet is appearing, making the overall experience feel more seamless?
Here is an approach with a custom sheet that brings in the keyboard somewhat earlier. Not sure if its worth the effort though:
struct ContentView: View {
#State var showSearch = false
var body: some View {
ZStack {
Button {
withAnimation {
showSearch = true
}
} label: {
Text("Search")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
if showSearch {
SearchView(isPresented: $showSearch)
.transition(.move(edge: .bottom))
}
}
// .sheet(isPresented: $showSearch) {
// SearchView()
// }
}
}
struct SearchView: View {
#Binding var isPresented: Bool
#State var searchTerm = ""
#FocusState private var searchFocus: Bool
var body: some View {
Form {
TextField("Search", text: $searchTerm)
.focused($searchFocus)
Button("Close") {
searchFocus = false
withAnimation {
isPresented = false
}
}
}
.onAppear() {
searchFocus = true
}
}
}
I have the following code:
enum ContentViewRouter {
case details
}
struct ContentView: View {
var body: some View {
NavigationStack {
ZStack {
NavigationLink(value: ContentViewRouter.details) {
Text("Push")
}
}
.navigationDestination(for: ContentViewRouter.self) { destiantion in
switch destiantion {
case .details:
DetailsView()
}
}
.navigationTitle("TEST")
}
}
}
struct DetailsView: View {
#State var query = ""
var body: some View {
Form {
Section("TEST") {
Text("DETAILS")
}
}
.searchable(text: $query)
.navigationTitle("DETAILS")
}
}
When pushing the details view it creates this animation:
I want to get rid of this weird looking animation "glitch":
How can I get the push animation to come in smooth where the search bar doesn't appear for a split second and then go away? Seems like it's a bug or I am doing it wrong?
The problem of that I think because you have .searchable which will create search bar and currently conflict with your .navigationTitle which both on the top. Maybe the search bar don't know where to put it because of the .navigationTitle
Update: After doing some research, I see that maybe the behavior maybe wrong in search bar .automatic
There are three ways to workaround
If you want to keep your search bar then make NavigationView to split define navigationTitle and search bar
struct DetailsView: View {
#State var query = ""
var body: some View {
NavigationView {
Form {
Section("TEST") {
Text("DETAILS")
}
}
.searchable(text: $query)
}
.navigationTitle("DETAILS")
}
}
Keep the search bar always appear
struct DetailsView: View {
#State var query = ""
var body: some View {
Form {
Section("TEST") {
Text("DETAILS")
}
}
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
.navigationTitle("DETAILS")
}
}
If you want to completely remove search bar
struct DetailsView: View {
#State var query = ""
var body: some View {
Form {
Section("TEST") {
Text("DETAILS")
}
}
//.searchable(text: $query) // remove this
.navigationTitle("DETAILS")
}
}
Consider the following example with a list and a button wrapped in a HStack that opens up a sheet:
struct ContentView: View {
#State var text: String = ""
#State var showSheet = false
var body: some View {
NavigationView {
List {
HStack {
button
}
Text("Hello World")
}
.searchable(text: $text)
}
}
var button: some View {
Button("Press", action: { showSheet = true })
.sheet(isPresented: $showSheet) {
modalView
}
}
var modalView: some View {
NavigationView {
List {
Text("Test")
}
}
}
}
On press of the button, a modal is presented to the user. However, the searchable modifier gets passed to the modal, see this video.
Now if the HStack is removed, everything works fine:
List {
button
Text("Hello World")
}
In addition, everything works also fine if the modal is not a NavigationView:
var modalView: some View {
List {
Text("Test")
}
}
Does somebody know what the problem here might be or is it once again one of those weird SwiftUI bugs?
putting the sheet, outside of the button and the List, works for me. I think .sheet is not meant to be inside a List, especially where searchable is operating.
struct ContentView: View {
#State var text: String = ""
#State var showSheet = false
var body: some View {
NavigationView {
List {
HStack {
button
}
Text("Hello World")
}
.searchable(text: $text)
}
.sheet(isPresented: $showSheet) {
modalView
}
}
var button: some View {
Button("Press", action: { showSheet = true })
}
var modalView: some View {
NavigationView {
List {
Text("Test")
}
}
}
}
Another workaround is to use navigationBarHidden = true, but then you must live without the navigation bar in the sheet view.
var modalView: some View {
NavigationView {
List {
Text("Test")
}
.navigationBarHidden(true)
}
}
Btw, on iPadOS it helps to use .searchable(text: $text, placement: .sidebar)
I have a simple search list:
struct ContentView: View {
#State var text:String = ""
var items = 1...100
var body: some View {
VStack {
List {
TextField("Search", text: $text)
Section{
ForEach(items.filter({"\($0)".contains(text)}),id: \.self){(i) in
Text("option \(i)")
}
}
}
}
}
}
How can I make the keyboard close when scrolling for more than 2 cells/few points?
If you are using a ScrollView (probably also with a List but I haven't confirmed it), you could use the UIScrollView appearance, this will affect all ScrollViews though.
UIScrollView.appearance().keyboardDismissMode = .onDrag
A thorough discussion on how to resign the keyboard with various answers can be found for this question.
One solution to resign the keyboard on a drag gesture in the list is using a method on UIApplication window as shown below. For easier handling I created an extension on UIApplication and view modifier for this extension and finally an extension to View:
extension UIApplication {
func endEditing(_ force: Bool) {
self.windows
.filter{$0.isKeyWindow}
.first?
.endEditing(force)
}
}
struct ResignKeyboardOnDragGesture: ViewModifier {
var gesture = DragGesture().onChanged{_ in
UIApplication.shared.endEditing(true)
}
func body(content: Content) -> some View {
content.gesture(gesture)
}
}
extension View {
func resignKeyboardOnDragGesture() -> some View {
return modifier(ResignKeyboardOnDragGesture())
}
}
So the final modifier for resigning the keyboard is just one modifier that has to be placed on the list like this:
List {
ForEach(...) {
//...
}
}
.resignKeyboardOnDragGesture()
I have also implemented a pure swiftUI version of a search bar that might be interesting for you. You can find it in this answer.
As for now, since iOS 16 beta we have a new modifier scrollDismissesKeyboard() that allows to do exactly what you need.
In your example it should look like
struct ContentView: View {
#State var text: String = ""
var items = 1...100
var body: some View {
List {
TextField("Search", text: $text)
Section {
ForEach(items.filter({"\($0)".contains(text)}), id: \.self) { (i) in
Text("option \(i)")
}
}
}
.scrollDismissesKeyboard(.interactively) // <<-- Put this line
}
}
The scrollDismissesKeyboard() modifier has a parameter that determine the dismiss rules. Here are the possible values:
.automatic: Dismissing based on the context of the scroll.
.immediately: The keyboard will be dismissed as soon as any scroll happens.
.interactively: The keyboard will move/disappear inline with the user’s gesture.
.never: The keyboard will never dismissed when user is scrolling.
Form {
...
}.gesture(DragGesture().onChanged { _ in
UIApplication.shared.windows.forEach { $0.endEditing(false) }
})
#FocusState wrapper along with .focused() TextField modifier can be useful.
struct ContentView: View {
#FocusState private var focusedSearchField: Bool
#State var text:String = ""
var items = 1...100
var body: some View {
VStack {
List {
TextField("Search", text: $text)
.focused($focusedSearchField)
Section{
ForEach(items.filter({"\($0)".contains(text)}),id: \.self){(i) in
Text("option \(i)")
}
}
} // to also allow swipes on items (theoretically)
.simultaneousGesture(DragGesture().onChanged({ _ in
focusedSearchField = false
}))
.onTapGesture { // dissmis on tap as well
focusedSearchField = false
}
}
}
}
struct EndEditingKeyboardOnDragGesture: ViewModifier {
func body(content: Content) -> some View {
content.highPriorityGesture (
DragGesture().onChanged { _ in
UIApplication.shared.endEditing()
}
)
}
}
extension View {
func endEditingKeyboardOnDragGesture() -> some View {
return modifier(EndEditingKeyboardOnDragGesture())
}
}
I was surprised that whenever I tap on the button embedded in PresentationLink, the same view reference shows up all the time. What I mean is that we don't create another instance of the view.
This makes sense as the destination object is created within the body property and will therefore not be recreated unless a change occur.
Do you guys know if we have a trivial way to recreate a new view every time we hit the button? Or is it by design and should be use like this?
Thank you!
EDIT
After #dfd 's comment, it seems to be by designed.
Now how to handle this use case:
Let's say I present a NavigationView and I pushed one view. If I
dismiss and re present, I will go back on the view I previously
pushed. In this case, I believe it's wrong as I'd like the user to go
through the complete flow every single time. How can I make sure that
I go back on the first screen everytime?
Thank you (again)!
EDIT 2
Here's some code:
struct PresenterExample : View {
var body: some View {
VStack {
PresentationLink(destination: CandidateCreateProfileJobView()) {
Text("Present")
}
}
}
}
struct StackFirstView : View {
var body: some View {
NavigationLink(destination: StackSecondView()) {
Text("Got to view 2")
}
}
}
struct StackSecondView : View {
var body: some View {
Text("View 2")
}
}
In this case that would be PresenterExample presents StackFirstView which will push StackSecondView from the NavigationLink.
From there, let's say the user swipe down and therefore dismiss the presentation. When it clicks back on the PresentationLink in PresenterExample it will open back on StackSecondView, which is not what I want. I want to display StackFirstView again.
Makes more sense? :)
First Try: Failure
I tried using the id modifier to tell SwiftUI to treat each presentation of StackFirstView as a completely new view unrelated to prior views:
import SwiftUI
struct PresenterExample : View {
var body: some View {
VStack {
PresentationLink("Present", destination:
StackFirstView()
.onDisappear {
print("onDisappear")
self.presentationCount += 1
}
)
}
}
#State private var presentationCount = 0
}
struct StackFirstView : View {
var body: some View {
NavigationView {
NavigationLink(destination: StackSecondView()) {
Text("Go to view 2")
}.navigationBarTitle("StackFirstView")
}
}
}
struct StackSecondView : View {
var body: some View {
Text("View 2")
.navigationBarTitle("StackSecondView")
}
}
import PlaygroundSupport
PlaygroundPage.current.liveView = UIHostingController(rootView: PresenterExample())
This should do the following:
It should identify the StackFirstView by presentationCount. SwiftUI should consider each StackFirstView with a different identifier to be a completely different view. I've used this successfully with animated transitions.
It should increment presentationCount when the StackFirstView is dismissed, so that the next StackFirstView gets a different identifier.
The problem is that SwiftUI never calls the onDisappear closure for the presented view or any of its subviews. I'm pretty sure this is a SwiftUI bug (as of Xcode 11 beta 3). I filed FB6687752.
Second Try: FailureSuccess
Next I tried managing the presentation myself, using the presentation(Modal?) modifier, so I wouldn't need the onDisappear modifier:
import SwiftUI
struct PresenterExample : View {
var body: some View {
VStack {
Button("Present") {
self.presentModal()
}.presentation(modal)
}
}
#State private var shouldPresent = false
#State private var presentationCount = 0
private func presentModal() {
presentationCount += 1
shouldPresent = true
}
private var modal: Modal? {
guard shouldPresent else { return nil }
return Modal(StackFirstView().id(presentationCount), onDismiss: { self.shouldPresent = false })
}
}
struct StackFirstView : View {
var body: some View {
NavigationView {
NavigationLink(destination: StackSecondView()) {
Text("Go to view 2")
}.navigationBarTitle("StackFirstView")
}
}
}
struct StackSecondView : View {
var body: some View {
Text("View 2")
}
}
import PlaygroundSupport
PlaygroundPage.current.liveView = UIHostingController(rootView: PresenterExample())
This fails in a different way. The second and later presentations of StackFirstView simply present a blank view instead. Again, I'm pretty sure this is a SwiftUI bug. I filed FB6687804.
I tried passing the presentationCount down to StackFirstView and then applying the .id(presentationCount) modifier to the NavigationView's content. That crashes the playground if the modal is dismissed and presented again while showing StackSecondView. I filed FB6687850.
Update
This tweet from Ryan Ashcraft showed me a workaround that gets this second attempt working. It wraps the Modal's content in a Group, and applies the id modifier to the Group's content:
import SwiftUI
struct PresenterExample : View {
var body: some View {
VStack {
Button("Present") {
self.presentModal()
}.presentation(modal)
}
}
#State private var shouldPresent = false
#State private var presentationCount = 0
private func presentModal() {
presentationCount += 1
shouldPresent = true
}
private var modal: Modal? {
guard shouldPresent else { return nil }
return Modal(Group { StackFirstView().id(presentationCount) }, onDismiss: { self.shouldPresent = false })
}
}
struct StackFirstView : View {
var body: some View {
NavigationView {
NavigationLink(destination: StackSecondView()) {
Text("Go to view 2")
}.navigationBarTitle("StackFirstView")
}
}
}
struct StackSecondView : View {
var body: some View {
Text("View 2")
}
}
import PlaygroundSupport
PlaygroundPage.current.liveView = UIHostingController(rootView: PresenterExample())
This revised second try successfully resets the state of the Modal on each presentation. Note that the id must be applied to the content of the Group, not to the Group itself, to work around the SwiftUI bug.
Third Try: Success
I modified the second try so that, instead of using the id modifier, it wraps the StackFirstView inside a ZStack when presentationCount is an odd number.
import SwiftUI
struct PresenterExample : View {
var body: some View {
VStack {
Button("Present") {
self.presentModal()
}.presentation(modal)
}
}
#State private var shouldPresent = false
#State private var presentationCount = 0
private func presentModal() {
presentationCount += 1
shouldPresent = true
}
private var modal: Modal? {
guard shouldPresent else { return nil }
if presentationCount.isMultiple(of: 2) {
return Modal(presentationContent, onDismiss: { self.shouldPresent = false })
} else {
return Modal(ZStack { presentationContent }, onDismiss: { self.shouldPresent = false })
}
}
private var presentationContent: some View {
StackFirstView()
}
}
struct StackFirstView : View {
var body: some View {
NavigationView {
NavigationLink(destination: StackSecondView()) {
Text("Go to view 2")
}.navigationBarTitle("StackFirstView")
}
}
}
struct StackSecondView : View {
var body: some View {
Text("View 2")
}
}
import PlaygroundSupport
PlaygroundPage.current.liveView = UIHostingController(rootView: PresenterExample())
This works. I guess SwiftUI sees that the modal's content is a different type each time (StackFirstView vs. ZStack<StackFirstView>) and that is sufficient to convince it that these are unrelated views, so it throws away the prior presented view instead of reusing it.