How can I control the preferred presentation size of a modal sheet on iPad with SwiftUI? I'm surprised how hard it is to find an answer on Google for this.
Also, what is the best way to know if the modal is dismissed by dragging it down (cancelled) or actually performing a custom positive action?
Here is my solution for showing a form sheet on an iPad in SwiftUI:
struct MyView: View {
#State var show = false
var body: some View {
Button("Open Sheet") { self.show = true }
.formSheet(isPresented: $show) {
Text("Form Sheet Content")
}
}
}
Enabled by this UIViewControllerRepresentable
class FormSheetWrapper<Content: View>: UIViewController, UIPopoverPresentationControllerDelegate {
var content: () -> Content
var onDismiss: (() -> Void)?
private var hostVC: UIHostingController<Content>?
required init?(coder: NSCoder) { fatalError("") }
init(content: #escaping () -> Content) {
self.content = content
super.init(nibName: nil, bundle: nil)
}
func show() {
guard hostVC == nil else { return }
let vc = UIHostingController(rootView: content())
vc.view.sizeToFit()
vc.preferredContentSize = vc.view.bounds.size
vc.modalPresentationStyle = .formSheet
vc.presentationController?.delegate = self
hostVC = vc
self.present(vc, animated: true, completion: nil)
}
func hide() {
guard let vc = self.hostVC, !vc.isBeingDismissed else { return }
dismiss(animated: true, completion: nil)
hostVC = nil
}
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
hostVC = nil
self.onDismiss?()
}
}
struct FormSheet<Content: View> : UIViewControllerRepresentable {
#Binding var show: Bool
let content: () -> Content
func makeUIViewController(context: UIViewControllerRepresentableContext<FormSheet<Content>>) -> FormSheetWrapper<Content> {
let vc = FormSheetWrapper(content: content)
vc.onDismiss = { self.show = false }
return vc
}
func updateUIViewController(_ uiViewController: FormSheetWrapper<Content>,
context: UIViewControllerRepresentableContext<FormSheet<Content>>) {
if show {
uiViewController.show()
}
else {
uiViewController.hide()
}
}
}
extension View {
public func formSheet<Content: View>(isPresented: Binding<Bool>,
#ViewBuilder content: #escaping () -> Content) -> some View {
self.background(FormSheet(show: isPresented,
content: content))
}
}
You should be able to modify the code in func show() according to UIKit specs in order to get the sizing the way you like (and you can even go so far as to inject parameters from the SwiftUI side if needed). This is just how I get a form sheet to work on iPad as .sheet was just too big for my use case
I just posted the same in this SO How can I make a background color with opacity on a Sheet view?
but it seems to do exactly what I need it to do. It makes the background of the sheet transparent while allowing the content to be sized as needed to appear as if it's the only part of the sheet. Works great on the iPad.
Using the AWESOME answer from #Asperi that I have been trying to find all day, I have built a simple view modifier that can now be applied inside a .sheet or .fullScreenCover modal view and provides a transparent background. You can then set the frame modifier for the content as needed to fit the screen without the user having to know the modal is not custom sized.
import SwiftUI
struct ClearBackgroundView: UIViewRepresentable {
func makeUIView(context: Context) -> some UIView {
let view = UIView()
DispatchQueue.main.async {
view.superview?.superview?.backgroundColor = .clear
}
return view
}
func updateUIView(_ uiView: UIViewType, context: Context) {
}
}
struct ClearBackgroundViewModifier: ViewModifier {
func body(content: Content) -> some View {
content
.background(ClearBackgroundView())
}
}
extension View {
func clearModalBackground()->some View {
self.modifier(ClearBackgroundViewModifier())
}
}
Usage:
.sheet(isPresented: $isPresented) {
ContentToDisplay()
.frame(width: 300, height: 400)
.clearModalBackground()
}
In case it helps anyone else, I was able to get this working by leaning on this code to hold the view controller:
https://gist.github.com/timothycosta/a43dfe25f1d8a37c71341a1ebaf82213
struct ViewControllerHolder {
weak var value: UIViewController?
init(_ value: UIViewController?) {
self.value = value
}
}
struct ViewControllerKey: EnvironmentKey {
static var defaultValue: ViewControllerHolder? { ViewControllerHolder(UIApplication.shared.windows.first?.rootViewController) }
}
extension EnvironmentValues {
var viewController: ViewControllerHolder? {
get { self[ViewControllerKey.self] }
set { self[ViewControllerKey.self] = newValue }
}
}
extension UIViewController {
func present<Content: View>(
presentationStyle: UIModalPresentationStyle = .automatic,
transitionStyle _: UIModalTransitionStyle = .coverVertical,
animated: Bool = true,
completion: #escaping () -> Void = { /* nothing by default*/ },
#ViewBuilder builder: () -> Content
) {
let toPresent = UIHostingController(rootView: AnyView(EmptyView()))
toPresent.modalPresentationStyle = presentationStyle
toPresent.rootView = AnyView(
builder()
.environment(\.viewController, ViewControllerHolder(toPresent))
)
if presentationStyle == .overCurrentContext {
toPresent.view.backgroundColor = .clear
}
present(toPresent, animated: animated, completion: completion)
}
}
Coupled with a specialized view to handle common elements in the modal:
struct ModalContentView<Content>: View where Content: View {
// Use this function to provide the content to display and to bring up the modal.
// Currently only the 'formSheet' style has been tested but it should work with any
// modal presentation style from UIKit.
public static func present(_ content: Content, style: UIModalPresentationStyle = .formSheet) {
let modal = ModalContentView(content: content)
// Present ourselves
modal.viewController?.present(presentationStyle: style) {
modal.body
}
}
// Grab the view controller out of the environment.
#Environment(\.viewController) private var viewControllerHolder: ViewControllerHolder?
private var viewController: UIViewController? {
viewControllerHolder?.value
}
// The content to be displayed in the view.
private var content: Content
public var body: some View {
VStack {
/// Some specialized controls, like X button to close omitted...
self.content
}
}
Finally, simply call:
ModalContentView.present( MyAwesomeView() )
to display MyAwesomeView inside of a .formSheet modal.
There are some issues in #ccwasden's answer. Dismissing popover won't change $isPresented all the time, as delegate is not set and hostVC is never assigned.
Here are some modifications required.
In FlexSheetWrapper:
func show() {
guard hostVC == nil else { return }
let vc = UIHostingController(rootView: content())
vc.view.sizeToFit()
vc.preferredContentSize = vc.view.bounds.size
vc.modalPresentationStyle = .formSheet
vc.presentationController?.delegate = self
hostVC = vc
self.present(vc, animated: true, completion: nil)
}
And in FormSheet:
func updateUIViewController(_ uiViewController: FlexSheetWrapper<Content>,
context: UIViewControllerRepresentableContext<FlexSheet<Content>>) {
if show {
uiViewController.show()
}
else {
uiViewController.hide()
}
}
From #ccwasden answer, I fixed the problem when you $isPresented = true at the beginning of code, the modal will not present when the view is loaded, To do so here is code View+FormSheet.swift
Result
// You can now set `test = true` at first
.formSheet(isPresented: $test) {
Text("Hi")
}
View+FormSheet.swift
import SwiftUI
class ModalUIHostingController<Content>: UIHostingController<Content>, UIPopoverPresentationControllerDelegate where Content : View {
var onDismiss: (() -> Void)
required init?(coder: NSCoder) { fatalError("") }
init(onDismiss: #escaping () -> Void, rootView: Content) {
self.onDismiss = onDismiss
super.init(rootView: rootView)
view.sizeToFit()
preferredContentSize = view.bounds.size
modalPresentationStyle = .formSheet
presentationController?.delegate = self
}
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
print("modal dismiss")
onDismiss()
}
}
class ModalUIViewController<Content: View>: UIViewController {
var isPresented: Bool
var content: () -> Content
var onDismiss: (() -> Void)
private var hostVC: ModalUIHostingController<Content>
private var isViewDidAppear = false
required init?(coder: NSCoder) { fatalError("") }
init(isPresented: Bool = false, onDismiss: #escaping () -> Void, content: #escaping () -> Content) {
self.isPresented = isPresented
self.onDismiss = onDismiss
self.content = content
self.hostVC = ModalUIHostingController(onDismiss: onDismiss, rootView: content())
super.init(nibName: nil, bundle: nil)
}
func show() {
guard isViewDidAppear else { return }
self.hostVC = ModalUIHostingController(onDismiss: onDismiss, rootView: content())
present(hostVC, animated: true)
}
func hide() {
guard !hostVC.isBeingDismissed else { return }
dismiss(animated: true)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(true)
isViewDidAppear = true
if isPresented {
show()
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
isViewDidAppear = false
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
show()
}
}
struct FormSheet<Content: View> : UIViewControllerRepresentable {
#Binding var show: Bool
let content: () -> Content
func makeUIViewController(context: UIViewControllerRepresentableContext<FormSheet<Content>>) -> ModalUIViewController<Content> {
let onDismiss = {
self.show = false
}
let vc = ModalUIViewController(isPresented: show, onDismiss: onDismiss, content: content)
return vc
}
func updateUIViewController(_ uiViewController: ModalUIViewController<Content>,
context: UIViewControllerRepresentableContext<FormSheet<Content>>) {
if show {
uiViewController.show()
}
else {
uiViewController.hide()
}
}
}
extension View {
public func formSheet<Content: View>(isPresented: Binding<Bool>,
#ViewBuilder content: #escaping () -> Content) -> some View {
self.background(FormSheet(show: isPresented,
content: content))
}
}
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)
}
}
Here's my SwiftUI code:
struct ContentView : View {
#State var showingTextField = false
#State var text = ""
var body: some View {
return VStack {
if showingTextField {
TextField($text)
}
Button(action: { self.showingTextField.toggle() }) {
Text ("Show")
}
}
}
}
What I want is when the text field becomes visible, to make the text field become the first responder (i.e. receive focus & have the keyboard pop up).
Using SwiftUI-Introspect, you can do:
TextField("", text: $value)
.introspectTextField { textField in
textField.becomeFirstResponder()
}
Swift UI 3
As of Xcode 13, you can use the focused modifier to make a view become first responder.
Swift UI 1/2
It doesn't seem to be possible at the moment, but you can implement something similar yourself.
You can create a custom text field and add a value to make it become first responder.
struct CustomTextField: UIViewRepresentable {
class Coordinator: NSObject, UITextFieldDelegate {
#Binding var text: String
var didBecomeFirstResponder = false
init(text: Binding<String>) {
_text = text
}
func textFieldDidChangeSelection(_ textField: UITextField) {
text = textField.text ?? ""
}
}
#Binding var text: String
var isFirstResponder: Bool = false
func makeUIView(context: UIViewRepresentableContext<CustomTextField>) -> UITextField {
let textField = UITextField(frame: .zero)
textField.delegate = context.coordinator
return textField
}
func makeCoordinator() -> CustomTextField.Coordinator {
return Coordinator(text: $text)
}
func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext<CustomTextField>) {
uiView.text = text
if isFirstResponder && !context.coordinator.didBecomeFirstResponder {
uiView.becomeFirstResponder()
context.coordinator.didBecomeFirstResponder = true
}
}
}
Note: didBecomeFirstResponder is needed to make sure the text field becomes first responder only once, not on every refresh by SwiftUI!
You would use it like this...
struct ContentView : View {
#State var text: String = ""
var body: some View {
CustomTextField(text: $text, isFirstResponder: true)
.frame(width: 300, height: 50)
.background(Color.red)
}
}
P.S. I added a frame as it doesn't behave like the stock TextField, meaning there's more stuff going on behind the scenes.
More on Coordinators in this excellent WWDC 19 talk:
Integrating SwiftUI
Tested on Xcode 11.4
iOS 15
There is a new wrapper called #FocusState that controls the state of the keyboard and the focused keyboard ('aka' firstResponder).
Become First Responder ( Focused )
If you use a focused modifier on the text fields, you can make them become focused:
Resign first responder ( Dismiss keyboard )
or dismiss the keyboard by setting the variable to nil:
iOS 13 and above: Old but working!
Simple wrapper struct - Works like a native:
Note that Text binding support added as requested in the comments
struct LegacyTextField: UIViewRepresentable {
#Binding public var isFirstResponder: Bool
#Binding public var text: String
public var configuration = { (view: UITextField) in }
public init(text: Binding<String>, isFirstResponder: Binding<Bool>, configuration: #escaping (UITextField) -> () = { _ in }) {
self.configuration = configuration
self._text = text
self._isFirstResponder = isFirstResponder
}
public func makeUIView(context: Context) -> UITextField {
let view = UITextField()
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
view.addTarget(context.coordinator, action: #selector(Coordinator.textViewDidChange), for: .editingChanged)
view.delegate = context.coordinator
return view
}
public func updateUIView(_ uiView: UITextField, context: Context) {
uiView.text = text
configuration(uiView)
switch isFirstResponder {
case true: uiView.becomeFirstResponder()
case false: uiView.resignFirstResponder()
}
}
public func makeCoordinator() -> Coordinator {
Coordinator($text, isFirstResponder: $isFirstResponder)
}
public class Coordinator: NSObject, UITextFieldDelegate {
var text: Binding<String>
var isFirstResponder: Binding<Bool>
init(_ text: Binding<String>, isFirstResponder: Binding<Bool>) {
self.text = text
self.isFirstResponder = isFirstResponder
}
#objc public func textViewDidChange(_ textField: UITextField) {
self.text.wrappedValue = textField.text ?? ""
}
public func textFieldDidBeginEditing(_ textField: UITextField) {
self.isFirstResponder.wrappedValue = true
}
public func textFieldDidEndEditing(_ textField: UITextField) {
self.isFirstResponder.wrappedValue = false
}
}
}
Usage:
struct ContentView: View {
#State var text = ""
#State var isFirstResponder = false
var body: some View {
LegacyTextField(text: $text, isFirstResponder: $isFirstResponder)
}
}
🎁 Bonus: Completely customizable
LegacyTextField(text: $text, isFirstResponder: $isFirstResponder) {
$0.textColor = .red
$0.tintColor = .blue
}
This method is fully adaptable. For example, you can see How to add an Activity indicator in SwiftUI with the same method here
iOS 15.0+
macOS 12.0+,
Mac Catalyst 15.0+,
tvOS 15.0+,
watchOS 8.0+
Use focused(_:) if you have a single TextField.
focused(_:)
Modifies this view by binding its focus state to the given Boolean state value.
struct NameForm: View {
#FocusState private var isFocused: Bool
#State private var name = ""
var body: some View {
TextField("Name", text: $name)
.focused($isFocused)
Button("Submit") {
if name.isEmpty {
isFocued = true
}
}
}
}
Use focused(_:equals:) should you have multiple TextFields.
focused(_:equals:)
Modifies this view by binding its focus state to the given state value.
struct LoginForm: View {
enum Field: Hashable {
case usernameField
case passwordField
}
#State private var username = ""
#State private var password = ""
#FocusState private var focusedField: Field?
var body: some View {
Form {
TextField("Username", text: $username)
.focused($focusedField, equals: .usernameField)
SecureField("Password", text: $password)
.focused($focusedField, equals: .passwordField)
Button("Sign In") {
if username.isEmpty {
focusedField = .usernameField
} else if password.isEmpty {
focusedField = .passwordField
} else {
handleLogin(username, password)
}
}
}
}
}
SwiftUI Documentation
focused(_:)
focused(_:equals:)
#FocusState
Update
I tested this in Xcode version 13.0 beta 5 (13A5212g). It works
For anyone who ended up here but faced crashed using #Matteo Pacini's answer, please be aware of this change in beta 4: Cannot assign to property: '$text' is immutable about this block:
init(text: Binding<String>) {
$text = text
}
should use:
init(text: Binding<String>) {
_text = text
}
And if you want to make the textfield become first responder in a sheet, please be aware that you cannot call becomeFirstResponder until the textfield is shown. In other words, putting #Matteo Pacini's textfield directly in sheet content causes crash.
To solve the issue, add an additional check uiView.window != nil for textfield's visibility. Only focus after it is in the view hierarchy:
struct AutoFocusTextField: UIViewRepresentable {
#Binding var text: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: UIViewRepresentableContext<AutoFocusTextField>) -> UITextField {
let textField = UITextField()
textField.delegate = context.coordinator
return textField
}
func updateUIView(_ uiView: UITextField, context:
UIViewRepresentableContext<AutoFocusTextField>) {
uiView.text = text
if uiView.window != nil, !uiView.isFirstResponder {
uiView.becomeFirstResponder()
}
}
class Coordinator: NSObject, UITextFieldDelegate {
var parent: AutoFocusTextField
init(_ autoFocusTextField: AutoFocusTextField) {
self.parent = autoFocusTextField
}
func textFieldDidChangeSelection(_ textField: UITextField) {
parent.text = textField.text ?? ""
}
}
}
📦 ResponderChain
I made this small package for cross-platform first responder handling without subclassing views or making custom ViewRepresentables in SwiftUI on iOS 13+
https://github.com/Amzd/ResponderChain
How to apply it to your problem
SceneDelegate.swift
...
// Set the ResponderChain as environmentObject
let rootView = ContentView().environmentObject(ResponderChain(forWindow: window))
...
ContentView.swift
struct ContentView: View {
#EnvironmentObject var chain: ResponderChain
#State var showingTextField = false
#State var text = ""
var body: some View {
return VStack {
if showingTextField {
TextField($text).responderTag("field1").onAppear {
DispatchQueue.main.async {
chain.firstResponder = "field1"
}
}
}
Button(action: { self.showingTextField.toggle() }) {
Text ("Show")
}
}
}
}
In my case I wanted to focused a textfield just right away I used .onappear function
struct MyView: View {
#FocusState private var isTitleTextFieldFocused: Bool
#State private var title = ""
var body: some View {
VStack {
TextField("Title", text: $title)
.focused($isTitleTextFieldFocused)
}
.padding()
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.isTitleTextFieldFocused = true
}
}
}
}
As others have noted (e.g. #kipelovets comment on the accepted answer, e.g. #Eonil's answer), I have also not found any of the accepted solutions to work on macOS. I've had some luck, however, using NSViewControllerRepresentable to get a NSSearchField to appear as the first responder in a SwiftUI view:
import Cocoa
import SwiftUI
class FirstResponderNSSearchFieldController: NSViewController {
#Binding var text: String
init(text: Binding<String>) {
self._text = text
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
let searchField = NSSearchField()
searchField.delegate = self
self.view = searchField
}
override func viewDidAppear() {
self.view.window?.makeFirstResponder(self.view)
}
}
extension FirstResponderNSSearchFieldController: NSSearchFieldDelegate {
func controlTextDidChange(_ obj: Notification) {
if let textField = obj.object as? NSTextField {
self.text = textField.stringValue
}
}
}
struct FirstResponderNSSearchFieldRepresentable: NSViewControllerRepresentable {
#Binding var text: String
func makeNSViewController(
context: NSViewControllerRepresentableContext<FirstResponderNSSearchFieldRepresentable>
) -> FirstResponderNSSearchFieldController {
return FirstResponderNSSearchFieldController(text: $text)
}
func updateNSViewController(
_ nsViewController: FirstResponderNSSearchFieldController,
context: NSViewControllerRepresentableContext<FirstResponderNSSearchFieldRepresentable>
) {
}
}
Sample SwiftUI view:
struct ContentView: View {
#State private var text: String = ""
var body: some View {
FirstResponderNSSearchFieldRepresentable(text: $text)
}
}
To fill in this missing functionality, you may install SwiftUIX using Swift Package Manager:
In Xcode, open your project and navigate to File → Swift Packages → Add Package Dependency...
Paste the repository URL (https://github.com/SwiftUIX/SwiftUIX) and click Next.
For Rules, select Branch (with branch set to master).
Click Finish.
Open the Project settings, add SwiftUI.framework to the Linked Frameworks and Libraries, set Status to Optional.
More Info: https://github.com/SwiftUIX/SwiftUIX
import SwiftUI
import SwiftUIX
struct ContentView : View {
#State var showingTextField = false
#State var text = ""
var body: some View {
return VStack {
if showingTextField {
CocoaTextField("Placeholder text", text: $text)
.isFirstResponder(true)
.frame(width: 300, height: 48, alignment: .center)
}
Button(action: { self.showingTextField.toggle() }) {
Text ("Show")
}
}
}
}
SwiftUI
struct ContentView: View {
enum Field {
case firstTextfield
case secondTextfield
case lastTextfield
}
#State private var firstTextfield = ""
#State private var secondTextfield = ""
#State private var lastTextfield = ""
#FocusState private var focusedField: Field?
var body: some View {
VStack {
TextField("Enter anything on first textfield", text: $firstTextfield)
.focused($focusedField, equals: .firstTextfield)
.submitLabel(.next)
TextField("Enter anything on second textfield", text: $secondTextfield)
.focused($focusedField, equals: .secondTextfield)
.submitLabel(.next)
TextField("Enter anything on last textfield", text: $lastTextfield)
.focused($focusedField, equals: .lastTextfield)
.submitLabel(.join)
}
.onSubmit {
switch focusedField {
case .firstTextfield:
focusedField = .secondTextfield
case .secondTextfield:
focusedField = .lastTextfield
default:
focusedField = nil
}
}
}
}
Description: Add an enum with textfields cases, and a property wrapped in a #FocusState with type of that enum. Add focused(_:equals:) modifier to have a binding value, equal to the enum cases. Now, you can change the focusedField to whichever textfield you want to have cursor on, or resign first responder by assigning nil to focusedField.
Not really an answer, just building on Casper's great solution with a convenient modifier -
struct StartInput: ViewModifier {
#EnvironmentObject var chain: ResponderChain
private let tag: String
init(tag: String) {
self.tag = tag
}
func body(content: Content) -> some View {
content.responderTag(tag).onAppear() {
DispatchQueue.main.async {
chain.firstResponder = tag
}
}
}
}
extension TextField {
func startInput(_ tag: String = "field") -> ModifiedContent<TextField<Label>, StartInput> {
self.modifier(StartInput(tag: tag))
}
}
Just use like -
TextField("Enter value:", text: $quantity)
.startInput()
This is a ViewModifier that works with introspect. It works for AppKit MacOS, Xcode 11.5
struct SetFirstResponderTextField: ViewModifier {
#State var isFirstResponderSet = false
func body(content: Content) -> some View {
content
.introspectTextField { textField in
if self.isFirstResponderSet == false {
textField.becomeFirstResponder()
self.isFirstResponderSet = true
}
}
}
}
We have a solution that makes controlling the first responder effortless.
https://github.com/mobilinked/MbSwiftUIFirstResponder
TextField("Name", text: $name)
.firstResponder(id: FirstResponders.name, firstResponder: $firstResponder, resignableUserOperations: .all)
TextEditor(text: $notes)
.firstResponder(id: FirstResponders.notes, firstResponder: $firstResponder, resignableUserOperations: .all)
Selected answer causes some infinite loop issue with AppKit. I don't know about UIKit case.
To avoid that issue, I recommend just sharing NSTextField instance directly.
import AppKit
import SwiftUI
struct Sample1: NSViewRepresentable {
var textField: NSTextField
func makeNSView(context:NSViewRepresentableContext<Sample1>) -> NSView { textField }
func updateNSView(_ x:NSView, context:NSViewRepresentableContext<Sample1>) {}
}
You can use that like this.
let win = NSWindow()
let txt = NSTextField()
win.setIsVisible(true)
win.setContentSize(NSSize(width: 256, height: 256))
win.center()
win.contentView = NSHostingView(rootView: Sample1(textField: txt))
win.makeFirstResponder(txt)
let app = NSApplication.shared
app.setActivationPolicy(.regular)
app.run()
This breaks pure value semantic, but depending on AppKit means you partially abandon pure value semantic and gonna afford some dirtiness. This is a magic hole we need right now for deal with lack of first-responder control in SwiftUI.
As we access NSTextField directly, setting first-responder is plain AppKit way, therefore no visible source of trouble.
You can download working source code here.
I took a little bit different approach - instead of UIViewRepresentable based on UITextField i made it based on UIView and and plug it in SwiftUI view hierarchy with background modifier. Inside UIView i added logic to find first view that canBecomeFirstResponder in subviews and parent views.
private struct FocusableUIView: UIViewRepresentable {
var isFirstResponder: Bool = false
class Coordinator: NSObject {
var didBecomeFirstResponder = false
}
func makeUIView(context: UIViewRepresentableContext<FocusableUIView>) -> UIView {
let view = UIView()
view.backgroundColor = .clear
return view
}
func makeCoordinator() -> FocusableUIView.Coordinator {
return Coordinator()
}
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<FocusableUIView>) {
guard uiView.window != nil, isFirstResponder, !context.coordinator.didBecomeFirstResponder else {
return
}
var foundRepsonder: UIView?
var currentSuperview: UIView? = uiView
repeat {
foundRepsonder = currentSuperview?.subviewFirstPossibleResponder
currentSuperview = currentSuperview?.superview
} while foundRepsonder == nil && currentSuperview != nil
guard let responder = foundRepsonder else {
return
}
DispatchQueue.main.async {
responder.becomeFirstResponder()
context.coordinator.didBecomeFirstResponder = true
}
}
}
private extension UIView {
var subviewFirstPossibleResponder: UIView? {
guard !canBecomeFirstResponder else { return self }
for subview in subviews {
if let firstResponder = subview.subviewFirstPossibleResponder {
return firstResponder
}
}
return nil
}
}
Here is an example how to use it to make TextField to autofocus (+ bonus utilise #FocusState new iOS 15 api).
extension View {
#ViewBuilder
func autofocus() -> some View {
if #available(iOS 15, *) {
modifier(AutofocusedViewModifiers.Modern())
} else {
modifier(AutofocusedViewModifiers.Legacy())
}
}
}
private enum AutofocusedViewModifiers {
struct Legacy: ViewModifier {
func body(content: Content) -> some View {
content
.background(FocusableUIView(isFirstResponder: isFocused))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isFocused = true
}
}
}
#State private var isFocused = false
}
#available(iOS 15, *)
struct Modern: ViewModifier {
func body(content: Content) -> some View {
content
.focused($isFocused)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isFocused = true
}
}
}
#FocusState private var isFocused: Bool
}
}
Content view example:
struct ContentView: View {
#State private var text = ""
var body: some View {
VStack {
TextField("placeholder", text: $text)
Text("some text")
}
.autofocus()
}
}
Since Responder Chain is not available to be consumed via SwiftUI, so we have to consume it using UIViewRepresentable.
Do have a look at the below link as I have made a workaround that can work similarly to the way we use to do using UIKit.
https://stackoverflow.com/a/61121199/6445871
It is my variant of the implementation, based on #Mojtaba Hosseini and #Matteo Pacini solutions.
I am still new to SwiftUI, so I won't guarantee the absolute correctness of the code, but it works.
I hope it would be helpful to someone.
ResponderView: It is a generic-responder view, that could be used with any UIKit view.
struct ResponderView<View: UIView>: UIViewRepresentable {
#Binding var isFirstResponder: Bool
var configuration = { (view: View) in }
func makeUIView(context: UIViewRepresentableContext<Self>) -> View { View() }
func makeCoordinator() -> Coordinator {
Coordinator($isFirstResponder)
}
func updateUIView(_ uiView: View, context: UIViewRepresentableContext<Self>) {
context.coordinator.view = uiView
_ = isFirstResponder ? uiView.becomeFirstResponder() : uiView.resignFirstResponder()
configuration(uiView)
}
}
// MARK: - Coordinator
extension ResponderView {
final class Coordinator {
#Binding private var isFirstResponder: Bool
private var anyCancellable: AnyCancellable?
fileprivate weak var view: UIView?
init(_ isFirstResponder: Binding<Bool>) {
_isFirstResponder = isFirstResponder
self.anyCancellable = Publishers.keyboardHeight.sink(receiveValue: { [weak self] keyboardHeight in
guard let view = self?.view else { return }
DispatchQueue.main.async { self?.isFirstResponder = view.isFirstResponder }
})
}
}
}
// MARK: - keyboardHeight
extension Publishers {
static var keyboardHeight: AnyPublisher<CGFloat, Never> {
let willShow = NotificationCenter.default.publisher(for: UIApplication.keyboardWillShowNotification)
.map { ($0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.height ?? 0 }
let willHide = NotificationCenter.default.publisher(for: UIApplication.keyboardWillHideNotification)
.map { _ in CGFloat(0) }
return MergeMany(willShow, willHide)
.eraseToAnyPublisher()
}
}
struct ResponderView_Previews: PreviewProvider {
static var previews: some View {
ResponderView<UITextField>.init(isFirstResponder: .constant(false)) {
$0.placeholder = "Placeholder"
}.previewLayout(.fixed(width: 300, height: 40))
}
}
ResponderTextField - It is a convenient text-field wrapper around ResponderView.
struct ResponderTextField: View {
var placeholder: String
#Binding var text: String
#Binding var isFirstResponder: Bool
private var textFieldDelegate: TextFieldDelegate
init(_ placeholder: String, text: Binding<String>, isFirstResponder: Binding<Bool>) {
self.placeholder = placeholder
self._text = text
self._isFirstResponder = isFirstResponder
self.textFieldDelegate = .init(text: text)
}
var body: some View {
ResponderView<UITextField>(isFirstResponder: $isFirstResponder) {
$0.text = self.text
$0.placeholder = self.placeholder
$0.delegate = self.textFieldDelegate
}
}
}
// MARK: - TextFieldDelegate
private extension ResponderTextField {
final class TextFieldDelegate: NSObject, UITextFieldDelegate {
#Binding private(set) var text: String
init(text: Binding<String>) {
_text = text
}
func textFieldDidChangeSelection(_ textField: UITextField) {
text = textField.text ?? ""
}
}
}
struct ResponderTextField_Previews: PreviewProvider {
static var previews: some View {
ResponderTextField("Placeholder",
text: .constant(""),
isFirstResponder: .constant(false))
.previewLayout(.fixed(width: 300, height: 40))
}
}
And way to use that.
struct SomeView: View {
#State private var login: String = ""
#State private var password: String = ""
#State private var isLoginFocused = false
#State private var isPasswordFocused = false
var body: some View {
VStack {
ResponderTextField("Login", text: $login, isFirstResponder: $isLoginFocused)
ResponderTextField("Password", text: $password, isFirstResponder: $isPasswordFocused)
}
}
}
Expanding on #JoshuaKifer answer's above, if you're dealing with the navigation animation being glitchy when using Introspect to make a text field first responder. Use this:
import SchafKit
#State var field: UITextField?
TextField("", text: $value)
.introspectTextField { textField in
field = textField
}
.onDidAppear {
field?.becomeFirstResponder()
}
More details on this solution here.
The correct SwiftUI way is to use #FocusState as mentioned above. However this API is available only for iOS 15. If you are using iOS 14 or iOS 13 you can use the Focuser library which is modelled to follow Apple API.
https://github.com/art-technologies/swift-focuser
Here's an example code. You will notice that API looks almost exactly as Apple, however Focuser also offers to use keyboard to move first responder down the chain which is pretty handy.
If you're having any problem with #JoshuaKifer or #ahaze 's response,
I've solved mine by using the modifier on the parent class, instead of on the TextField itself.
What I was doing:
TextField("Placeholder text...", text: $searchInput)
.introspectTextField { textField in
textField.becomeFirstResponder()
}
How I solved my problem:
YourParentStruct(searchInput: $searchInput)
.introspectTextField { textField in
textField.becomeFirstResponder()
}
I'll put the definition of the parent struct below just for clearness
struct YourParentStruct: View {
#Binding var searchInput: String
var body: some View {
HStack {
TextField("Placeholder text...", text: $searchInput)
.padding()
.background(Color.gray)
.cornerRadius(10)
}
}
}
At its Simplest form in iOS 13 ( without using any third party sdk/repo , or if you havent upgraded to iOS 14 to utilise the focus modifiers)
struct CustomTextField: UIViewRepresentable {
func makeUIView(context: Context) -> UITextField {
UITextField(frame: .zero)
}
func updateUIView(_ uiView: UITextField, context: Context) {
uiView.becomeFirstResponder()
}
}
Usage:
struct ContentView : View {
var body: some View {
CustomTextField()
.frame(width: 300, height: 50)
.background(Color.red)
}
}
I know its too late but if it helps anyone this is how I do it.
import SwiftUI
import Introspect
struct MyView: View {
#Binding var text1: String
#Binding var text2: String
#State private var toggleTF: Bool = false
var body: some View {
TextField("TextField 1", text: $text1)
.introspectTextField{ tF in
if toggleTF {
tF.becomeFirstResponder()
}
}
TextField("TextField 2", text: $text1)
.introspectTextField{ tF in
if !toggleTF {
tF.becomeFirstResponder()
}
}
Button("Toggle textfields") {
toggleTF.toggle()
}
}
}
As SwiftUI 2 doesn't support first responders yet I use this solution. It is dirty, but might work for some use cases when you only have 1 UITextField and 1 UIWindow.
import SwiftUI
struct MyView: View {
#Binding var text: String
var body: some View {
TextField("Hello world", text: $text)
.onAppear {
UIApplication.shared.windows.first?.rootViewController?.view.textField?.becomeFirstResponder()
}
}
}
private extension UIView {
var textField: UITextField? {
subviews.compactMap { $0 as? UITextField }.first ??
subviews.compactMap { $0.textField }.first
}
}