I'm trying to dismiss the keyboard when view dismissed but keyboard not dismiss properly, they only hide button but they showing height. More detail please check my code and screenshot.
Keyboard shows properly
Go To next Screen when keyboard open and dismiss
Keyboard button hide but show height with background
import SwiftUI
struct ContentView: View {
#State private var titleDtr: String = ""
#State private var clearAllText: Bool = false
#State var updateKeyboard = true
#State var userProfiles = [""]
var body: some View {
NavigationView{
HStack{
CustomTxtfldForFollowerScrn(isDeleteAcntScreen: .constant(false), text: $titleDtr, clearAllText: $clearAllText, isFirstResponder: $updateKeyboard, completion: { (reponse) in
if titleDtr.count >= 3 {
userProfiles.append(titleDtr)
}else if titleDtr.count <= 3 {
userProfiles.removeAll()
}
}).background(Color.red)
VStack(spacing:0) {
List {
if userProfiles.count > 0 {
ForEach(userProfiles.indices, id: \.self) { indexs in
NavigationLink(destination: ShowLoadingText()) {
Text(userProfiles[indexs]).foregroundColor(.blue)
}
}
}
}
}
}.onDisappear{
updateKeyboard = false
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
import SwiftUI
struct ShowLoadingText: View {
var body: some View {
ZStack {
VStack(alignment:.center, spacing: 15) {
HStack(spacing:10){
Group {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Color.white))
Text("Loading More Users...")
}
}.foregroundColor(.black)
}
}
}
}
struct CustomTxtfldForFollowerScrn: UIViewRepresentable {
#Binding var isDeleteAcntScreen: Bool
#Binding var text: String
#Binding var clearAllText: Bool
#Binding var isFirstResponder: Bool
var completion: (String) -> Void
func makeUIView(context: UIViewRepresentableContext<CustomTxtfldForFollowerScrn>) -> UITextField {
let textField = UITextField(frame: .zero)
textField.text = text
textField.delegate = context.coordinator
textField.backgroundColor = .clear
if isDeleteAcntScreen {
textField.placeholder = "DELETE"
}else{
textField.placeholder = "Username"
}
textField.returnKeyType = .default
textField.textColor = .black
return textField
}
func makeCoordinator() -> CustomTxtfldForFollowerScrn.Coordinator {
return Coordinator(self)
}
func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext<CustomTxtfldForFollowerScrn>) {
uiView.text = text
if isFirstResponder && !context.coordinator.didBecomeFirstResponder {
uiView.becomeFirstResponder()
context.coordinator.didBecomeFirstResponder = true
}else if clearAllText {
DispatchQueue.main.async {
uiView.text! = ""
text = ""
clearAllText = false
}
}else if !isFirstResponder {
DispatchQueue.main.async {
UIApplication.shared.endEditing()
}
}
}
class Coordinator: NSObject, UITextFieldDelegate {
var didBecomeFirstResponder = false
var parent: CustomTxtfldForFollowerScrn
init(_ view: CustomTxtfldForFollowerScrn) {
self.parent = view
}
func textFieldDidChangeSelection(_ textField: UITextField) {
DispatchQueue.main.async {
self.parent.text = textField.text ?? ""
self.parent.completion(textField.text!)
}
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
}
}
import UIKit
extension UIApplication {
func endEditing() {
sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
Here is the alternative way to dismissing the keyboard.
First, remove the main queue.
func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext<CustomTxtfldForFollowerScrn>) {
uiView.text = text
if isFirstResponder && !context.coordinator.didBecomeFirstResponder {
uiView.becomeFirstResponder()
context.coordinator.didBecomeFirstResponder = true
}else if clearAllText {
DispatchQueue.main.async {
uiView.text! = ""
text = ""
clearAllText = false
}
}else if !isFirstResponder { // < -- From here
UIApplication.shared.endEditing()
}
}
and then update updateKeyboard on ShowLoadingText() appear.
ForEach(userProfiles.indices, id: \.self) { indexs in
NavigationLink(destination: ShowLoadingText().onAppear() { updateKeyboard = false }) { // <-- Here
Text(userProfiles[indexs]).foregroundColor(.blue)
}
}
Remove onDisappear code.
Related
So I'm trying to click on a button and change the mapType using the MKMapView API, but I can't seem to achieve it.
So here is what I have, we have the MKMapView file:
import SwiftUI
import MapKit
struct MapViewUIKit: UIViewRepresentable {
// Environment Objects
#EnvironmentObject var mainViewModel: MainViewModel
#EnvironmentObject private var mapSettings: MapSettings
// Coordinator function
final class Coordinator: NSObject, MKMapViewDelegate {
// Define this class.
var parent: MapViewUIKit
// Initialize this class.
init(_ parent: MapViewUIKit) {
self.parent = parent
}
// MARK: Display Annotation
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
// Unrelated code here.
}
// MARK: Select Annotation
func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
// Unrelated code here.
}
}
//
func makeCoordinator() -> Coordinator {
MapViewUIKit.Coordinator(self)
}
// MARK: CREATE MAP
func makeUIView(context: Context) -> MKMapView {
// Create map.
let mapView = MKMapView(frame: .zero)
// Coordinate our delegate.
mapView.delegate = context.coordinator
// Set our region for the map.
mapView.setRegion(DEFAULT_MK_REGION, animated: false)
// Set our map type to standard.
mapView.mapType = .standard
// Show user location
mapView.showsUserLocation = true
return mapView
}
// MARK: UPDATE MAP
func updateUIView(_ uiView: MKMapView, context: Context) {
updateMapType(uiView)
}
// Update our map type on selection.
private func updateMapType(_ uiView: MKMapView) {
switch mapSettings.mapType {
case 0:
if #available(iOS 16.0, *) {
let config = MKStandardMapConfiguration(elevationStyle: elevationStyle(), emphasisStyle: emphasisStyle())
config.pointOfInterestFilter = MKPointOfInterestFilter.excludingAll
config.showsTraffic = false
} else {
// Fallback on earlier versions
}
case 1:
if #available(iOS 16.0, *) {
uiView.preferredConfiguration = MKHybridMapConfiguration(elevationStyle: elevationStyle())
} else {
// Fallback
}
case 2:
if #available(iOS 16.0, *) {
uiView.preferredConfiguration = MKImageryMapConfiguration(elevationStyle: elevationStyle())
} else {
// Fallback on earlier versions
}
default:
break
}
}
// Set the elevation style.
#available(iOS 16.0, *)
private func elevationStyle() -> MKMapConfiguration.ElevationStyle {
if mapSettings.showElevation == 0 {
return MKMapConfiguration.ElevationStyle.realistic
} else {
return MKMapConfiguration.ElevationStyle.flat
}
}
// Set the emphasis style.
#available(iOS 16.0, *)
private func emphasisStyle() -> MKStandardMapConfiguration.EmphasisStyle {
if mapSettings.showEmphasisStyle == 0 {
return MKStandardMapConfiguration.EmphasisStyle.default
} else {
return MKStandardMapConfiguration.EmphasisStyle.muted
}
}
}
Then I have my MapDisplaySheetView, which contains the following buttons:
Here is the code that I am using:
struct MapDisplaySheetView: View {
#ObservedObject var mapSettings = MapSettings()
#Environment(\.dismiss) var dismiss
#State var mapType = 0
#State var showElevation = 0
#State var showEmphasis = 0
#State var mapDisplay: [String] = [
"Standard",
"Hybrid",
"Image",
]
#State var mapElevation: [String] = [
"Realistic",
"Flat",
]
#State var mapEmphasis: [String] = [
"Default",
"Muted",
]
var body: some View {
VStack(spacing: 0) {
// MARK: MapType
HStack {
ForEach(mapDisplay, id: \.self) { item in
VStack {
HStack {
VStack {
Button(action: {
switch item {
case "Standard": mapSettings.mapType = 0
case "Hybrid": mapSettings.mapType = 1
case "Image": mapSettings.mapType = 2
default: mapSettings.mapType = 0
}
print("User has selected \(item) map type.")
}, label: {
ZStack {
Text(item)
.multilineTextAlignment(.center)
}
}) //: Button
} //: VStack
} //: HStack
}
.onChange(of: mapType) { newValue in
mapSettings.mapType = newValue
log.info("The new map type is: \(newValue)")
}
} //: ForEach
} //: HStack
// MARK: Map Elevation
HStack {
ForEach(mapElevation, id: \.self) { item in
VStack {
HStack {
VStack {
Button(action: {
switch item {
case "Realistic": mapSettings.showElevation = 0
case "Flat": mapSettings.showElevation = 1
default: mapSettings.showElevation = 0
}
print("User has selected \(item) map elevation.")
}, label: {
ZStack {
Text(item)
.multilineTextAlignment(.center)
}
}) //: Button
}
}
}
}
ForEach(mapEmphasis, id: \.self) { item in
VStack {
HStack {
VStack {
Button(action: {
switch item {
case "Default": mapSettings.showEmphasisStyle = 0
case "Muted": mapSettings.showEmphasisStyle = 1
default: mapSettings.showEmphasisStyle = 0
}
print("User has selected \(item) map emphasis.")
}, label: {
ZStack {
Text(item)
.multilineTextAlignment(.center)
}
}) //: Button
}
}
}
}
} //: HStack
}
}
}
// Mapping
final class MapSettings: ObservableObject {
#Published var mapType = 0
#Published var showElevation = 0
#Published var showEmphasisStyle = 0
}
I am attempting to use case 0 for the top 3 buttons, which are the mapType and then case 1 for the bottom 2 left buttons and then case 2 for the bottom 2 right buttons, but I can't seem to get the map to update at all, which I believe there is an issue inside MapViewUIKit and got the mapType is set.
Could the issue be with mapView.mapType = .standard?
I am using this guide as an example: https://holyswift.app/new-mapkit-configurations-with-swiftui/
First, change your settings to a struct (you don't need a reference type in this situation):
struct MapSettings {
var mapType = 0
var showElevation = 0
var showEmphasisStyle = 0
}
Then fix the #ObservedObject to:
#State var mapSettings = MapSettings()
Then you can do
struct MapViewUIKit: UIViewRepresentable {
let mapSettings: MapSettings
updateUIView will be called when mapSettings has changed from the last time this View was init, and then you can use the new values to make any changes to MKMapView if required.
Another mistake is Coordinator(self), self is just a value which is immediately discarded after SwiftUI updates so that won't work, try this structure instead:
struct MyMapView: UIViewRepresentable {
#Binding var userTrackingMode: MapUserTrackingMode
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> MKMapView {
context.coordinator.mapView
}
func updateUIView(_ uiView: MKMapView, context: Context) {
// MKMapView has a strange design that the delegate is called when setting manually so we need to prevent an infinite loop
context.coordinator.userTrackingModeChanged = nil
uiView.userTrackingMode = userTrackingMode == .follow ? MKUserTrackingMode.follow : MKUserTrackingMode.none
context.coordinator.userTrackingModeChanged = { mode in
userTrackingMode = mode == .follow ? MapUserTrackingMode.follow : MapUserTrackingMode.none
}
}
class Coordinator: NSObject, MKMapViewDelegate {
lazy var mapView: MKMapView = {
let mv = MKMapView()
mv.delegate = self
return mv
}()
var userTrackingModeChanged: ((MKUserTrackingMode) -> Void)?
func mapView(_ mapView: MKMapView, didChange mode: MKUserTrackingMode, animated: Bool) {
userTrackingModeChanged?(mode)
}
}
}
I have a textField and next to it a button that should clear the text. My problems is though that the TextField View only carries a binder. Hence the actual text in the textfield isn't deleted since Bindings doesn't reload views.
I tried to narrow it down as much as possible in an example:
struct ContentView: View {
#State var presentedView = false
#State var text: String = ""
var body: some View {
Button("Hello, world!") { presentedView = true }
.padding()
.sheet(isPresented: $presentedView, content: {
SomeView(viewModel: .init(textBinder: $text), text: $text)
})
}
}
struct SomeView: View {
let viewModel: ViewModel
#Binding var text: String
var body: some View {
HStack {
TextFieldTyped(text: $text)
Spacer()
Button("erase") {
$text.wrappedValue = ""
}
}
}
struct ViewModel {
let textBinder: Binding<String>
}
}
struct TextFieldTyped: UIViewRepresentable {
#Binding var text: String
func makeUIView(context: Context) -> UITextField {
let textField = UITextField(frame: .zero)
textField.delegate = context.coordinator
return textField
}
func updateUIView(_ uiView: UITextField, context: Context) {
uiView.becomeFirstResponder()
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UITextFieldDelegate {
var parent: TextFieldTyped
init(_ textField: TextFieldTyped) {
self.parent = textField
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if (textField.text?.count ?? 0) == 1, string.isEmpty {
parent.text = string
} else if string.isEmpty, !(textField.text ?? "").isEmpty {
parent.text = String(textField.text?.dropLast() ?? "")
} else {
parent.text = (textField.text ?? "").isEmpty ? string : (textField.text ?? "") + string
}
return true
}
}
}
try the following code without the useless ViewModel, and an update of text in updateUIView:
struct ContentView: View {
#State var presentedView = false
#State var text: String = ""
var body: some View {
Button("Hello, world!") { presentedView = true }
.padding()
.sheet(isPresented: $presentedView, content: {
SomeView(text: $text)
})
}
}
struct SomeView: View {
#Binding var text: String
var body: some View {
HStack {
TextFieldTyped(text: $text)
Spacer()
Button("erase") {
text = "" // <--- here
}
}
}
}
struct TextFieldTyped: UIViewRepresentable {
#Binding var text: String
func makeUIView(context: Context) -> UITextField {
let textField = UITextField(frame: .zero)
textField.delegate = context.coordinator
return textField
}
func updateUIView(_ uiView: UITextField, context: Context) {
uiView.becomeFirstResponder()
uiView.text = text // <--- here
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UITextFieldDelegate {
var parent: TextFieldTyped
init(_ textField: TextFieldTyped) {
self.parent = textField
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if (textField.text?.count ?? 0) == 1, string.isEmpty {
parent.text = string
} else if string.isEmpty, !(textField.text ?? "").isEmpty {
parent.text = String(textField.text?.dropLast() ?? "")
} else {
parent.text = (textField.text ?? "").isEmpty ? string : (textField.text ?? "") + string
}
return true
}
}
}
The other answer is perfectly fine but it is also good idea to pull related vars out into their own struct, as recommended in Data Essentials in SwiftUI (WWDC20) at 4:00. Here is how I think it would be achieved in your case:
struct SomeViewConfig {
var presented = false
var text: String = ""
mutating func present() {
presented = true
text = ""
}
}
struct ContentView2: View {
#State var someViewConfig = SomeViewConfig()
var body: some View {
Button("Hello, world!") { someViewConfig.present() }
.padding()
.sheet(isPresented: $someViewConfig.presented) {
SomeView(config: $someViewConfig)
}
}
}
struct SomeView: View {
#Binding var config: SomeViewConfig
var body: some View {
HStack {
TextFieldTyped(text: $config.text)
Spacer()
Button("erase") {
config.text = ""
}
}
}
}
is it possible in SwiftUI to keep the typing cursor on the same Textfield even after the user taps on Return key on keyboard ?
Here is my code:
struct RowView: View {
#Binding var checklistItem: ChecklistItem
#ObservedObject var checklist = Checklist()
#ObservedObject var viewModel: ChecklistViewModel
var body: some View {
HStack {
Button {
self.checklistItem.isChecked.toggle()
self.viewModel.updateChecklist(checklistItem)
} label: {
Circle()
.strokeBorder(checklistItem.isChecked ? checklistSelected : contentPrimary, lineWidth: checklistItem.isChecked ? 6 : 2)
.foregroundColor(backgroundSecondary)
.clipShape(Circle())
.frame(width: 16, height: 16)
}.buttonStyle(BorderlessButtonStyle())
// swiftlint:disable trailing_closure
TextField(
"Add...",
text: $checklistItem.name,
onCommit: {
do {
if !checklistItem.name.isEmpty {
self.viewModel.updateChecklist(checklistItem)
self.checklistItem.name = checklistItem.name
}
}
}
)
// swiftlint:enable trailing_closure
.foregroundColor(checklistItem.isChecked ? contentTertiary : contentPrimary)
Spacer()
}
}
}
So after the user taps on return key on keyboard, TextField() onCommit should be activated normally but the cursor stays in that same textfield so the user can keep typing in new elements.
iOS 15+
You can use #FocusState and, on commit, immediately set the TextField to have focus again.
Example:
struct ContentView: View {
#State private var text = "Hello world!"
#FocusState private var isFieldFocused: Bool
var body: some View {
Form {
TextField("Field", text: $text, onCommit: {
isFieldFocused = true
print("onCommit")
})
.focused($isFieldFocused)
}
}
}
Result:
I was able to achieve this in iOS 14 by creating a custom TextField class:
struct AlwaysActiveTextField: UIViewRepresentable {
let placeholder: String
#Binding var text: String
var focusable: Binding<[Bool]>?
var returnKeyType: UIReturnKeyType = .next
var autocapitalizationType: UITextAutocapitalizationType = .none
var keyboardType: UIKeyboardType = .default
var isSecureTextEntry: Bool
var tag: Int
var onCommit: () -> Void
func makeUIView(context: Context) -> UITextField {
let activeTextField = UITextField(frame: .zero)
activeTextField.delegate = context.coordinator
activeTextField.placeholder = placeholder
activeTextField.font = .systemFont(ofSize: 14)
activeTextField.attributedPlaceholder = NSAttributedString(
string: placeholder,
attributes: [NSAttributedString.Key.foregroundColor: UIColor(contentSecondary)]
)
activeTextField.returnKeyType = returnKeyType
activeTextField.autocapitalizationType = autocapitalizationType
activeTextField.keyboardType = keyboardType
activeTextField.isSecureTextEntry = isSecureTextEntry
activeTextField.textAlignment = .left
activeTextField.tag = tag
// toolbar
if keyboardType == .numberPad { // keyboard does not have next so add next button in the toolbar
var items = [UIBarButtonItem]()
let spacer = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
let toolbar: UIToolbar = UIToolbar()
toolbar.sizeToFit()
let nextButton = UIBarButtonItem(title: "Next", style: .plain, target: context.coordinator, action: #selector(Coordinator.showNextTextField))
items.append(contentsOf: [spacer, nextButton])
toolbar.setItems(items, animated: false)
activeTextField.inputAccessoryView = toolbar
}
// Editin listener
activeTextField.addTarget(context.coordinator, action: #selector(Coordinator.textFieldDidChange(_:)), for: .editingChanged)
return activeTextField
}
func updateUIView(_ uiView: UITextField, context: Context) {
uiView.text = text
if let focusable = focusable?.wrappedValue {
if focusable[uiView.tag] { // set focused
uiView.becomeFirstResponder()
} else { // remove keyboard
uiView.resignFirstResponder()
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
final class Coordinator: NSObject, UITextFieldDelegate {
let activeTextField: AlwaysActiveTextField
var hasEndedViaReturn = false
weak var textField: UITextField?
init(_ activeTextField: AlwaysActiveTextField) {
self.activeTextField = activeTextField
}
func textFieldDidBeginEditing(_ textField: UITextField) {
self.textField = textField
guard let textFieldCount = activeTextField.focusable?.wrappedValue.count else { return }
var focusable: [Bool] = Array(repeating: false, count: textFieldCount) // remove focus from all text field
focusable[textField.tag] = true // mark current textField focused
activeTextField.focusable?.wrappedValue = focusable
}
// work around for number pad
#objc
func showNextTextField() {
if let textField = self.textField {
_ = textFieldShouldReturn(textField)
}
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
hasEndedViaReturn = true
guard var focusable = activeTextField.focusable?.wrappedValue else {
textField.resignFirstResponder()
return true
}
focusable[textField.tag] = true // mark current textField focused
activeTextField.focusable?.wrappedValue = focusable
activeTextField.onCommit()
return true
}
func textFieldDidEndEditing(_ textField: UITextField) {
if !hasEndedViaReturn {// user dismisses keyboard
guard let textFieldCount = activeTextField.focusable?.wrappedValue.count else { return }
// reset all text field, so that makeUIView cannot trigger keyboard
activeTextField.focusable?.wrappedValue = Array(repeating: false, count: textFieldCount)
} else {
hasEndedViaReturn = false
}
}
#objc
func textFieldDidChange(_ textField: UITextField) {
activeTextField.text = textField.text ?? ""
}
}
}
and use in in the SwiftUI view by adding this #State variable:
#State var fieldFocus: [Bool] = [false]
and add the Textfield code it self anywhere waiting the view body:
AlwaysActiveTextField(
placeholder: "Add...",
text: $newItemName,
focusable: $fieldFocus,
returnKeyType: .next,
isSecureTextEntry: false,
tag: 0,
onCommit: {
print("any action you want on commit")
}
)
Starting point is a NavigationView within a TabView. I'm struggling with finding a SwiftUI solution to pop to the root view within the navigation stack when the selected tab is tapped again. In the pre-SwiftUI times, this was as simple as the following:
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
let navController = viewController as! UINavigationController
navController.popViewController(animated: true)
}
Do you know how the same thing can be achieved in SwiftUI?
Currently, I use the following workaround that relies on UIKit:
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
let navigationController = UINavigationController(rootViewController: UIHostingController(rootView: MyCustomView() // -> this is a normal SwiftUI file
.environment(\.managedObjectContext, context)))
navigationController.tabBarItem = UITabBarItem(title: "My View 1", image: nil, selectedImage: nil)
// add more controllers that are part of tab bar controller
let tabBarController = UITabBarController()
tabBarController.viewControllers = [navigationController /* , additional controllers */ ]
window.rootViewController = tabBarController // UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
Here is possible approach. For TabView it gives the same behaviour as tapping to the another tab and back, so gives persistent look & feel.
Tested & works with Xcode 11.2 / iOS 13.2
Full module code:
import SwiftUI
struct TestPopToRootInTab: View {
#State private var selection = 0
#State private var resetNavigationID = UUID()
var body: some View {
let selectable = Binding( // << proxy binding to catch tab tap
get: { self.selection },
set: { self.selection = $0
// set new ID to recreate NavigationView, so put it
// in root state, same as is on change tab and back
self.resetNavigationID = UUID()
})
return TabView(selection: selectable) {
self.tab1()
.tabItem {
Image(systemName: "1.circle")
}.tag(0)
self.tab2()
.tabItem {
Image(systemName: "2.circle")
}.tag(1)
}
}
private func tab1() -> some View {
NavigationView {
NavigationLink(destination: TabChildView()) {
Text("Tab1 - Initial")
}
}.id(self.resetNavigationID) // << making id modifiable
}
private func tab2() -> some View {
Text("Tab2")
}
}
struct TabChildView: View {
var number = 1
var body: some View {
NavigationLink("Child \(number)",
destination: TabChildView(number: number + 1))
}
}
struct TestPopToRootInTab_Previews: PreviewProvider {
static var previews: some View {
TestPopToRootInTab()
}
}
Here's an approach that uses a PassthroughSubject to notify the child view whenever the tab is re-selected, and a view modifier to allow you to attach .onReselect() to a view.
import SwiftUI
import Combine
enum TabSelection: String {
case A, B, C // etc
}
private struct DidReselectTabKey: EnvironmentKey {
static let defaultValue: AnyPublisher<TabSelection, Never> = Just(.Mood).eraseToAnyPublisher()
}
private struct CurrentTabSelection: EnvironmentKey {
static let defaultValue: Binding<TabSelection> = .constant(.Mood)
}
private extension EnvironmentValues {
var tabSelection: Binding<TabSelection> {
get {
return self[CurrentTabSelection.self]
}
set {
self[CurrentTabSelection.self] = newValue
}
}
var didReselectTab: AnyPublisher<TabSelection, Never> {
get {
return self[DidReselectTabKey.self]
}
set {
self[DidReselectTabKey.self] = newValue
}
}
}
private struct ReselectTabViewModifier: ViewModifier {
#Environment(\.didReselectTab) private var didReselectTab
#State var isVisible = false
let action: (() -> Void)?
init(perform action: (() -> Void)? = nil) {
self.action = action
}
func body(content: Content) -> some View {
content
.onAppear {
self.isVisible = true
}.onDisappear {
self.isVisible = false
}.onReceive(didReselectTab) { _ in
if self.isVisible, let action = self.action {
action()
}
}
}
}
extension View {
public func onReselect(perform action: (() -> Void)? = nil) -> some View {
return self.modifier(ReselectTabViewModifier(perform: action))
}
}
struct NavigableTabViewItem<Content: View>: View {
#Environment(\.didReselectTab) var didReselectTab
let tabSelection: TabSelection
let imageName: String
let content: Content
init(tabSelection: TabSelection, imageName: String, #ViewBuilder content: () -> Content) {
self.tabSelection = tabSelection
self.imageName = imageName
self.content = content()
}
var body: some View {
let didReselectThisTab = didReselectTab.filter( { $0 == tabSelection }).eraseToAnyPublisher()
NavigationView {
self.content
.navigationBarTitle(tabSelection.localizedStringKey, displayMode: .inline)
}.tabItem {
Image(systemName: imageName)
Text(tabSelection.localizedStringKey)
}
.tag(tabSelection)
.navigationViewStyle(StackNavigationViewStyle())
.keyboardShortcut(tabSelection.keyboardShortcut)
.environment(\.didReselectTab, didReselectThisTab)
}
}
struct NavigableTabView<Content: View>: View {
#State private var didReselectTab = PassthroughSubject<TabSelection, Never>()
#State private var _selection: TabSelection = .Mood
let content: Content
init(#ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
let selection = Binding(get: { self._selection },
set: {
if self._selection == $0 {
didReselectTab.send($0)
}
self._selection = $0
})
TabView(selection: selection) {
self.content
.environment(\.tabSelection, selection)
.environment(\.didReselectTab, didReselectTab.eraseToAnyPublisher())
}
}
}
Here's how I did it:
struct UIKitTabView: View {
var viewControllers: [UIHostingController<AnyView>]
init(_ tabs: [Tab]) {
self.viewControllers = tabs.map {
let host = UIHostingController(rootView: $0.view)
host.tabBarItem = $0.barItem
return host
}
}
var body: some View {
TabBarController(controllers: viewControllers).edgesIgnoringSafeArea(.all)
}
struct Tab {
var view: AnyView
var barItem: UITabBarItem
init<V: View>(view: V, barItem: UITabBarItem) {
self.view = AnyView(view)
self.barItem = barItem
}
}
}
struct TabBarController: UIViewControllerRepresentable {
var controllers: [UIViewController]
func makeUIViewController(context: Context) -> UITabBarController {
let tabBarController = UITabBarController()
tabBarController.viewControllers = controllers
tabBarController.delegate = context.coordinator
return tabBarController
}
func updateUIViewController(_ uiViewController: UITabBarController, context: Context) { }
}
extension TabBarController {
func makeCoordinator() -> TabBarController.Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UITabBarControllerDelegate {
var parent: TabBarController
init(_ parent: TabBarController){self.parent = parent}
var previousController: UIViewController?
private var shouldSelectIndex = -1
func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
shouldSelectIndex = tabBarController.selectedIndex
return true
}
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
if shouldSelectIndex == tabBarController.selectedIndex {
if let navVC = tabBarController.viewControllers![shouldSelectIndex].nearestNavigationController {
if (!(navVC.popViewController(animated: true) != nil)) {
navVC.viewControllers.first!.scrollToTop()
}
}
}
}
}
}
extension UIViewController {
var nearestNavigationController: UINavigationController? {
if let selfTypeCast = self as? UINavigationController {
return selfTypeCast
}
if children.isEmpty {
return nil
}
for child in self.children {
return child.nearestNavigationController
}
return nil
}
}
extension UIViewController {
func scrollToTop() {
func scrollToTop(view: UIView?) {
guard let view = view else { return }
switch view {
case let scrollView as UIScrollView:
if scrollView.scrollsToTop == true {
scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.safeAreaInsets.top), animated: true)
return
}
default:
break
}
for subView in view.subviews {
scrollToTop(view: subView)
}
}
scrollToTop(view: view)
}
}
Then in ContentView.swift I use it like this:
struct ContentView: View {
var body: some View {
ZStack{
UIKitTabView([
UIKitTabView.Tab(
view: FirstView().edgesIgnoringSafeArea(.top),
barItem: UITabBarItem(title: "Tab1", image: UIImage(systemName: "star"), selectedImage: UIImage(systemName: "star.fill"))
),
UIKitTabView.Tab(
view: SecondView().edgesIgnoringSafeArea(.top),
barItem: UITabBarItem(title: "Tab2", image: UIImage(systemName: "star"), selectedImage: UIImage(systemName: "star.fill"))
),
])
}
}
}
Note that when the user is already on the root view, it scrolls to top automatically
Here's what I did with introspect swiftUI library.
https://github.com/siteline/SwiftUI-Introspect
struct TabBar: View {
#State var tabSelected: Int = 0
#State var navBarOne: UINavigationController?
#State var navBarTwo: UINavigationController?
#State var navBarThree: UINavigationController?
var body: some View {
return TabView(selection: $tabSelected){
NavView(navigationView: $navBarOne).tabItem {
Label("Home1",systemImage: "bag.fill")
}.tag(0)
NavView(navigationView: $navBarTwo).tabItem {
Label("Orders",systemImage: "scroll.fill" )
}.tag(1)
NavView(navigationView: $navBarThree).tabItem {
Label("Wallet", systemImage: "dollarsign.square.fill" )
// Image(systemName: tabSelected == 2 ? "dollarsign.square.fill" : "dollarsign.square")
}.tag(2)
}.onTapGesture(count: 2) {
switch tabSelected{
case 0:
self.navBarOne?.popToRootViewController(animated: true)
case 1:
self.navBarTwo?.popToRootViewController(animated: true)
case 2:
self.navBarThree?.popToRootViewController(animated: true)
default:
print("tapped")
}
}
}
}
NavView:
import SwiftUI
import Introspect
struct NavView: View {
#Binding var navigationView: UINavigationController?
var body: some View {
NavigationView{
VStack{
NavigationLink(destination: Text("Detail view")) {
Text("Go To detail")
}
}.introspectNavigationController { navController in
navigationView = navController
}
}
}
}
This actually isn't the best approach because it makes the entire tab view and everything inside of it have the double-tap gesture which would pop the view to its root. My current fix for this allows for one tap to pop up root view haven't figured out how to add double tap
struct TabBar: View {
#State var tabSelected: Int = 0
#State var navBarOne: UINavigationController?
#State var navBarTwo: UINavigationController?
#State var navBarThree: UINavigationController?
#State var selectedIndex:Int = 0
var selectionBinding: Binding<Int> { Binding(
get: {
self.selectedIndex
},
set: {
if $0 == self.selectedIndex {
popToRootView(tabSelected: $0)
}
self.selectedIndex = $0
}
)}
var body: some View {
return TabView(selection: $tabSelected){
NavView(navigationView: $navBarOne).tabItem {
Label("Home1",systemImage: "bag.fill")
}.tag(0)
NavView(navigationView: $navBarTwo).tabItem {
Label("Orders",systemImage: "scroll.fill" )
}.tag(1)
NavView(navigationView: $navBarThree).tabItem {
Label("Wallet", systemImage: "dollarsign.square.fill" )
// Image(systemName: tabSelected == 2 ? "dollarsign.square.fill" : "dollarsign.square")
}.tag(2)
}
}
func popToRootView(tabSelected: Int){
switch tabSelected{
case 0:
self.navBarOne?.popToRootViewController(animated: true)
case 1:
self.navBarTwo?.popToRootViewController(animated: true)
case 2:
self.navBarThree?.popToRootViewController(animated: true)
default:
print("tapped")
}
}
}
I took an approach similar to Asperi
Use a combination of a custom binding, and a separately stored app state var for keeping state of the navigation link.
The custom binding allows you to see all taps basically even when the current tab is the one thats tapped, something that onChange of tab selection binding doesn't show. This is what imitates the UIKit TabViewDelegate behavior.
This doesn't require a "double tap", if you just a single tap of the current, if you want double tap you'll need to implement your own tap/time tracking but shouldn't be too hard.
class AppState: ObservableObject {
#Published var mainViewShowingDetailView = false
}
struct ContentView: View {
#State var tabState: Int = 0
#StateObject var appState = AppState()
var body: some View {
let binding = Binding<Int>(get: { tabState },
set: { newValue in
if newValue == tabState { // tapped same tab they're already on
switch newValue {
case 0: appState.mainViewShowingDetailView = false
default: break
}
}
tabState = newValue // make sure you actually set the storage
})
TabView(selection: binding) {
MainView()
.tabItem({ Label("Home", systemImage: "list.dash") })
.tag(0)
.environmentObject(appState)
}
}
}
struct MainView: View {
#EnvironmentObject var appState: AppState
var body: {
NavigationView {
VStack {
Text("Hello World")
NavigationLink(destination: DetailView(),
isActive: $appState.mainViewShowingDetailView,
label: { Text("Show Detail") })
}
}
}
}
struct DetailView: View {
...
}
iOS 16 / NavigationStack approach with PassthroughSubject
Uses willSet on selectedTab to get the tap event, and uses a PassthroughSubject for sending the event to the children. This is picked up by the .onReceived and calls a function for popping the views from the NavigationStack
Did a full write up here: https://kentrobin.com/home/tap-tab-to-go-back/ and created a working demo project here: https://github.com/kentrh/demo-tap-tab-to-go-back
class HomeViewModel: ObservableObject {
#Published var selectedTab: Tab = .tab1 {
willSet {
if selectedTab == newValue {
subject.send(newValue)
}
}
}
let subject = PassthroughSubject<Tab, Never>()
enum Tab: Int {
case tab1 = 0
}
}
struct HomeView: View {
#StateObject var viewModel: HomeViewModel = .init()
var body: some View {
TabView(selection: $viewModel.selectedTab) {
Tab1View(subject: viewModel.subject)
.tag(HomeViewModel.Tab.tab1)
.tabItem {
Label("Tab 1", systemImage: "1.lane")
Text("Tab 1", comment: "Tab bar title")
}
}
}
}
struct Tab1View: View {
#StateObject var viewModel: Tab1ViewModel = .init()
let subject: PassthroughSubject<HomeViewModel.Tab, Never>
var body: some View {
NavigationStack(path: $viewModel.path) {
List {
NavigationLink(value: Tab1ViewModel.Route.viewOne("From tab 1")) {
Text("Go deeper to OneView")
}
NavigationLink(value: Tab1ViewModel.Route.viewTwo("From tab 1")) {
Text("Go deeper to TwoView")
}
}
.navigationTitle("Tab 1")
.navigationDestination(for: Tab1ViewModel.Route.self, destination: { route in
switch route {
case let .viewOne(text):
Text(text)
case let .viewTwo(text):
Text(text)
}
})
.onReceive(subject) { tab in
if case .tab1 = tab { viewModel.tabBarTapped() }
}
}
}
}
class Tab1ViewModel: ObservableObject {
#Published var path: [Route] = []
func tabBarTapped() {
if path.count > 0 {
path.removeAll()
}
}
enum Route: Hashable {
case viewOne(String)
case viewTwo(String)
}
}
Context
I have created a UIViewRepresentable to wrap a UITextField so that:
it can be set it to become the first responder when the view loads.
the next textfield can be set to become the first responder when enter is pressed
Problem
When used inside a NavigationView, unless the keyboard is dismissed from previous views, the view doesn't observe the value in their ObservedObject.
Question
Why is this happening? What can I do to fix this behaviour?
Screenshots
Keyboard from root view not dismissed:
Keyboard from root view dismissed:
Code
Here is the said UIViewRepresentable
struct SimplifiedFocusableTextField: UIViewRepresentable {
#Binding var text: String
private var isResponder: Binding<Bool>?
private var placeholder: String
private var tag: Int
public init(
_ placeholder: String = "",
text: Binding<String>,
isResponder: Binding<Bool>? = nil,
tag: Int = 0
) {
self._text = text
self.placeholder = placeholder
self.isResponder = isResponder
self.tag = tag
}
func makeUIView(context: UIViewRepresentableContext<SimplifiedFocusableTextField>) -> UITextField {
// create textfield
let textField = UITextField()
// set delegate
textField.delegate = context.coordinator
// configure textfield
textField.placeholder = placeholder
textField.setContentHuggingPriority(.defaultHigh, for: .vertical)
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textField.tag = self.tag
// return
return textField
}
func makeCoordinator() -> SimplifiedFocusableTextField.Coordinator {
return Coordinator(text: $text, isResponder: self.isResponder)
}
func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext<SimplifiedFocusableTextField>) {
// update text
uiView.text = text
// set first responder ONCE
if self.isResponder?.wrappedValue == true && !uiView.isFirstResponder && !context.coordinator.didBecomeFirstResponder{
uiView.becomeFirstResponder()
context.coordinator.didBecomeFirstResponder = true
}
}
class Coordinator: NSObject, UITextFieldDelegate {
#Binding var text: String
private var isResponder: Binding<Bool>?
var didBecomeFirstResponder = false
init(text: Binding<String>, isResponder: Binding<Bool>?) {
_text = text
self.isResponder = isResponder
}
func textFieldDidChangeSelection(_ textField: UITextField) {
text = textField.text ?? ""
}
func textFieldDidBeginEditing(_ textField: UITextField) {
DispatchQueue.main.async {
self.isResponder?.wrappedValue = true
}
}
func textFieldDidEndEditing(_ textField: UITextField) {
DispatchQueue.main.async {
self.isResponder?.wrappedValue = false
}
}
}
}
And to reproduce, here is the contentView:
struct ContentView: View {
var body: some View {
return NavigationView { FieldView(tag: 0) }
}
}
and here's the view with the field and its view model
struct FieldView: View {
#ObservedObject private var viewModel = FieldViewModel()
#State private var focus = false
var tag: Int
var body: some View {
return VStack {
// listen to viewModel's value
Text(viewModel.value)
// text field
SimplifiedFocusableTextField("placeholder", text: self.$viewModel.value, isResponder: $focus, tag: self.tag)
// push to stack
NavigationLink(destination: FieldView(tag: self.tag + 1)) {
Text("Continue")
}
// dummy for tapping to dismiss keyboard
Color.green
}
.onAppear {
self.focus = true
}.dismissKeyboardOnTap()
}
}
public extension View {
func dismissKeyboardOnTap() -> some View {
modifier(DismissKeyboardOnTap())
}
}
public struct DismissKeyboardOnTap: ViewModifier {
public func body(content: Content) -> some View {
return content.gesture(tapGesture)
}
private var tapGesture: some Gesture {
TapGesture().onEnded(endEditing)
}
private func endEditing() {
UIApplication.shared.connectedScenes
.filter {$0.activationState == .foregroundActive}
.map {$0 as? UIWindowScene}
.compactMap({$0})
.first?.windows
.filter {$0.isKeyWindow}
.first?.endEditing(true)
}
}
class FieldViewModel: ObservableObject {
var subscriptions = Set<AnyCancellable>()
// diplays
#Published var value = ""
}
It looks like SwiftUI rendering engine again over-optimized...
Here is fixed part - just make destination unique forcefully using .id. Tested with Xcode 11.4 / iOS 13.4
NavigationLink(destination: FieldView(tag: self.tag + 1).id(UUID())) {
Text("Continue")
}