Custom UITextField wrapped in UIViewRepresentable interfering with ObservableObject in NavigationView - swiftui

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

Related

Clearing Binding text to textfield still shows text in textfield

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

SwiftUI: stay on same TextField after on commit?

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

Keyboard dismiss not working properly when dismiss the view swiftui

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.

TabView SwiftUI return to Home page on click [duplicate]

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

How can you move the cursor to the end in a SwiftUI TextField?

I am using a SwiftUI TextField with a Binding String to change the user's input into a phone format. Upon typing, the formatting is happening, but the cursor isn't moved to the end of the textfield, it remains on the position it was when it was entered. For example, if I enter 1, the value of the texfield (after formatting) will be (1, but the cursor stays after the first character, instead of at the end of the line.
Is there a way to move the textfield's cursor to the end of the line?
Here is the sample code:
import SwiftUI
import AnyFormatKit
struct ContentView: View {
#State var phoneNumber = ""
let phoneFormatter = DefaultTextFormatter(textPattern: "(###) ###-####")
var body: some View {
let phoneNumberProxy = Binding<String>(
get: {
return (self.phoneFormatter.format(self.phoneNumber) ?? "")
},
set: {
self.phoneNumber = self.phoneFormatter.unformat($0) ?? ""
}
)
return TextField("Phone Number", text: phoneNumberProxy)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You might have to use UITextField instead of TextField. UITextField allows setting custom cursor position. To position the cursor at the end of the text you can use textField.endOfDocument to set UITextField.selectedTextRange when the text content is updated.
#objc func textFieldDidChange(_ textField: UITextField) {
let newPosition = textField.endOfDocument
textField.selectedTextRange = textField.textRange(from: newPosition, to: newPosition)
}
The following SwiftUI code snippet shows a sample implementation.
import SwiftUI
import UIKit
//import AnyFormatKit
struct ContentView: View {
#State var phoneNumber = ""
let phoneFormatter = DefaultTextFormatter(textPattern: "(###) ###-####")
var body: some View {
let phoneNumberProxy = Binding<String>(
get: {
return (self.phoneFormatter.format(self.phoneNumber) ?? "")
},
set: {
self.phoneNumber = self.phoneFormatter.unformat($0) ?? ""
}
)
return TextFieldContainer("Phone Number", text: phoneNumberProxy)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
/************************************************/
struct TextFieldContainer: UIViewRepresentable {
private var placeholder : String
private var text : Binding<String>
init(_ placeholder:String, text:Binding<String>) {
self.placeholder = placeholder
self.text = text
}
func makeCoordinator() -> TextFieldContainer.Coordinator {
Coordinator(self)
}
func makeUIView(context: UIViewRepresentableContext<TextFieldContainer>) -> UITextField {
let innertTextField = UITextField(frame: .zero)
innertTextField.placeholder = placeholder
innertTextField.text = text.wrappedValue
innertTextField.delegate = context.coordinator
context.coordinator.setup(innertTextField)
return innertTextField
}
func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext<TextFieldContainer>) {
uiView.text = self.text.wrappedValue
}
class Coordinator: NSObject, UITextFieldDelegate {
var parent: TextFieldContainer
init(_ textFieldContainer: TextFieldContainer) {
self.parent = textFieldContainer
}
func setup(_ textField:UITextField) {
textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
}
#objc func textFieldDidChange(_ textField: UITextField) {
self.parent.text.wrappedValue = textField.text ?? ""
let newPosition = textField.endOfDocument
textField.selectedTextRange = textField.textRange(from: newPosition, to: newPosition)
}
}
}
Unfortunately I can't comment on ddelver's excellent answer, but I just wanted to add that for me, this did not work when I changed the bound string.
My use case is that I had a custom text field component used to edit the selected item from a list, so as you change selected item, the bound string changes. This meant that TextFieldContainer's init method was being called whenever the binding changed, but parent inside the Coordinator still referred to the initial parent.
I'm new to Swift so there may be a better fix for this, but I fixed it by adding a method to the Coordinator:
func updateParent(_ parent : TextFieldContainer) {
self.parent = parent
}
and then calling this from func updateUIView like:
func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext<TextFieldContainer>) {
uiView.text = self.text.wrappedValue
context.coordinator.updateParent(self)
}
You can do something like this:
final class ContentViewModel: ObservableObject {
private let phoneFormatter = DefaultTextFormatter(textPattern: "(###) ###-####")
private var realPhoneNumber = ""
#Published var formattedPhoneNumber = "" {
didSet {
let formattedText = phoneFormatter.format(formattedPhoneNumber) ?? ""
// Need this check to avoid infinite loop
if formattedPhoneNumber != formattedText {
let realText = phoneFormatter.unformat(formattedPhoneNumber) ?? ""
formattedPhoneNumber = formattedText
realPhoneNumber = realText
}
}
}
}
struct ContentView: View {
#StateObject var viewModel = ContentViewModel()
var body: some View {
return TextField("Phone Number", text: $viewModel.formattedPhoneNumber)
}
}
The idea here is that when you manually set (assign) the text binding, the cursor of the textField moves to the end of the text.