I am using UITabBarController in SwiftUI 2.0 and Xcode 12 but seems like Keyboard cases some unexpected behavior. As you can see from the below GIF, OnAppear of the other 2 tab's view called when the keyboard appears in the first tab. That is causing the issue as I have an API call written on appear.
Also, is there any way I can turn off the default view offset behavior of Xcode 12.
Here is my code of Content View.
struct ContentView: View {
#State private var index:Int = 0
var menuItems:[String] = ["Item 1", "Item 2", "Item 3"]
var body: some View {
NavigationView(content: {
ZStack{
MyTabView(selectedIndex: self.$index)
.view(item: self.item1) {
NewView(title: "Hello1").navigationBarTitle("")
.navigationBarHidden(true)
}
.view(item: self.item2) {
NewView(title: "Hello2").navigationBarTitle("")
.navigationBarHidden(true)
}
.view(item: self.item3) {
NewView(title: "Hello3").navigationBarTitle("")
.navigationBarHidden(true)
}
}.navigationBarHidden(true)
.navigationBarTitle("")
})
}
var item1:MyTabItem {
var item = MyTabItem()
item.imageName = "pencil.circle"
item.selectedImageName = "pencil.circle.fill"
return item
}
var item2:MyTabItem {
var item = MyTabItem()
item.imageName = "pencil.circle"
item.selectedImageName = "pencil.circle.fill"
return item
}
var item3:MyTabItem {
var item = MyTabItem()
item.imageName = "pencil.circle"
item.selectedImageName = "pencil.circle.fill"
return item
}
}
struct NewView:View {
#State var text:String = ""
var title:String
var body: some View {
VStack {
Spacer()
Text("Hello")
TextField(title, text: self.$text)
.textFieldStyle(RoundedBorderTextFieldStyle())
}.padding()
.onAppear {
debugPrint("OnApper \(self.title)")
}
}
}
and here is the code for CustomTabView.
class MyTabViewViewModel:ObservableObject {
var controllers: [UIViewController] = []
var tabItems:[MyTabItem] = []
}
struct MyTabItem {
var imageName:String = ""
var selectedImageName:String = ""
var hasDarkModeSupport:Bool = true
var image:UIImage?
var selectedImage:UIImage?
}
struct MyTabView: UIViewControllerRepresentable {
var viewModel:MyTabViewViewModel = MyTabViewViewModel()
#Binding var selectedIndex: Int
func makeUIViewController(context: Context) -> UITabBarController {
let tabBarController = UITabBarController()
tabBarController.viewControllers = self.viewModel.controllers
tabBarController.delegate = context.coordinator
tabBarController.selectedIndex = 0
let appearance = tabBarController.tabBar.standardAppearance
appearance.shadowImage = nil
appearance.shadowColor = nil
appearance.backgroundEffect = nil
tabBarController.tabBar.standardAppearance = appearance
tabBarController.tabBar.shadowImage = UIImage()
tabBarController.tabBar.backgroundImage = UIImage()
tabBarController.tabBar.layer.shadowPath = UIBezierPath(rect: tabBarController.tabBar.bounds).cgPath
tabBarController.tabBar.layer.shadowOffset = CGSize.init(width: 0, height: -3)
tabBarController.tabBar.layer.shadowRadius = 5
tabBarController.tabBar.layer.shadowColor = UIColor.black.cgColor
tabBarController.tabBar.layer.shadowOpacity = 0.25
tabBarController.tabBar.backgroundColor = UIColor.white
tabBarController.tabBar.barTintColor = UIColor.white
self.updateTabItems(forTabBarController: tabBarController)
return tabBarController
}
func updateUIViewController(_ tabBarController: UITabBarController, context: Context) {
tabBarController.selectedIndex = selectedIndex
self.updateTabItems(forTabBarController: tabBarController)
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func updateTabItems(forTabBarController tabBarController:UITabBarController) {
let isDarkModeEnable:Bool = tabBarController.traitCollection.userInterfaceStyle == .dark
for (index, tabItem) in self.viewModel.tabItems.enumerated() {
tabBarController.tabBar.items?[index].title = ""
if let image = tabItem.image {
tabBarController.tabBar.items?[index].image = image
if let selectedImage = tabItem.selectedImage {
tabBarController.tabBar.items?[index].selectedImage = selectedImage
}
} else {
if tabItem.hasDarkModeSupport && isDarkModeEnable {
if let image = UIImage.init(systemName: "\(tabItem.imageName)-dark") {
tabBarController.tabBar.items?[index].image = image
} else if let image = UIImage.init(systemName: tabItem.imageName) {
tabBarController.tabBar.items?[index].image = image
}
if let selectedImage = UIImage.init(systemName: "\(tabItem.selectedImageName)-dark") {
tabBarController.tabBar.items?[index].selectedImage = selectedImage
} else if let selectedImage = UIImage.init(systemName: tabItem.selectedImageName) {
tabBarController.tabBar.items?[index].selectedImage = selectedImage
}
} else {
if let image = UIImage.init(systemName: tabItem.imageName) {
tabBarController.tabBar.items?[index].image = image
}
if let selectedImage = UIImage.init(systemName: tabItem.selectedImageName) {
tabBarController.tabBar.items?[index].selectedImage = selectedImage
}
}
}
}
}
class Coordinator: NSObject, UITabBarControllerDelegate {
var parent: MyTabView
init(_ tabBarController: MyTabView) {
self.parent = tabBarController
}
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
parent.selectedIndex = tabBarController.selectedIndex
}
}
func view<HostedView:View>(item:MyTabItem, #ViewBuilder sheet: #escaping () -> HostedView) -> MyTabView {
self.viewModel.controllers.append(UIHostingController.init(rootView: sheet()))
self.viewModel.tabItems.append(item)
return self
}
}
Having the same issue myself
"Hackish" workaround is to wrap the NewView.body in a List:
#State var text:String = ""
var title:String
var body: some View {
List {
VStack {
Spacer()
Text("Hello")
TextField(title, text: self.$text)
.textFieldStyle(RoundedBorderTextFieldStyle())
}.padding()
.onAppear {
debugPrint("OnApper \(self.title)")
}
}
}
}
Could also work to use a LazyVStack, but haven't gotten to test it as my project targets 13.x
Same issue here OnAppear calls unexpectedly when Keyboard Appears in SwiftUI
Related
I am trying to pass an admob(test) Id string from firebase to my BannerViewController. However, the view does not change when I modify my #State variable when executing my function. I placed a text to see the change in real time.
The variable 'theStringAd' should change to "ca-app-pub-3940256099942544/2934735716" when onAppear fires my function loadAd_Chart_()
this is what the ContentView look like
struct ContentView: View {
#State var theStringAd: String = "string should change"
//theStringAd should change to "ca-app-pub-3940256099942544/2934735716" when onAppear fires my function loadAd_Chart_()
var body: some View {
NavigationView{
ScrollView{
VStack{
make_ad_view(coolString: $theStringAd)
Text("The String: \(theStringAd)")
}
}
}.onAppear{
loadAd_Chart_()
}
}
func loadAd_Chart_() {
Firestore.firestore().collection("chartData").document("vqzVQOEzsbbOW1j11Oc6").getDocument { (docs, error) in
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
if let documentSnapshot = docs, documentSnapshot.exists {
print("loading")
let ad_one_database = documentSnapshot.get("ad_one") as? String ?? ""
theStringAd = ad_one_database
print(theStringAd)
}
}
}
}
}
I know it receives the id because it prints "ca-app-pub-3940256099942544/2934735716"
Banner View
struct make_ad_view: View {
#Binding var coolString: String
var body: some View {
VStack{
BannerAdView(adUnit: $coolString, adFormat: .regularBanner)
}
}
}
Banner controller
enum AdFormat {
case largeBanner
case mediumRectangle
case adaptiveBanner
case regularBanner
var adSize: GADAdSize {
switch self {
case .largeBanner: return GADAdSizeLargeBanner
case .mediumRectangle: return GADAdSizeMediumRectangle
case .regularBanner: return GADAdSizeBanner
case .adaptiveBanner: return
GADCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(UIScreen.main.bounds.size.width)
}
}
var size: CGSize {
adSize.size
}
}
enum AdStatus {
case loading
case success
case failure
}
final class BannerViewController: UIViewControllerRepresentable {
#Binding var adUnitID: String
let adSize: GADAdSize
#Binding var adStatus: AdStatus
init( adUnitID: Binding<String>, adSize: GADAdSize, adStatus: Binding<AdStatus>) {
self._adUnitID = adUnitID
self.adSize = adSize
self._adStatus = adStatus
}
func makeCoordinator() -> Coordinator {
Coordinator(bannerViewController: self)
}
func makeUIViewController(context: Context) -> UIViewController {
let viewController = UIViewController()
let view = GADBannerView(adSize: adSize)
view.delegate = context.coordinator
view.adUnitID = adUnitID
view.rootViewController = viewController
view.load(GADRequest())
viewController.view.addSubview(view)
viewController.view.frame = CGRect(origin: .zero, size: adSize.size)
return viewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
class Coordinator: NSObject, GADBannerViewDelegate {
var bannerViewController: BannerViewController
init(bannerViewController: BannerViewController) {
self.bannerViewController = bannerViewController
}
func adView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) {
bannerViewController.adStatus = .failure
}
func adViewDidReceiveAd(_ bannerView: GADBannerView) {
bannerViewController.adStatus = .success
}
}
}
struct BannerAdView: View {
#Binding var adUnit: String
let adFormat: AdFormat
#State var adStatus: AdStatus = .loading
var body: some View {
HStack {
if adStatus != .failure {
ZStack{
Text("Ad")
.font(.title)
.opacity(0.5)
BannerViewController(adUnitID: $adUnit, adSize: adFormat.adSize, adStatus: $adStatus)
.frame(width: adFormat.size.width, height: adFormat.size.height)
}.frame(width: adFormat.size.width, height: adFormat.size.height)
}
}
.frame(maxWidth: .infinity)
.background(Color(.secondarySystemFill))
}
}
Is it possible to add a view modifier inside .onChange?
Simplified example:
content
.onChange(of: publishedValue) {
content.foregroundColor(.red)
}
I have a theme that when changed needs to change the status bar color. I have a view modifier created for that ( https://barstool.engineering/set-the-ios-status-bar-style-in-swiftui-using-a-custom-view-modifier/ ). The modifier works fine, but I need to update it as the publishedValue changes.
Actual minimal example:
import SwiftUI
struct ContentView: View {
#ObservedObject var viewModel: TestViewModel
var body: some View {
ZStack {
Rectangle().foregroundColor(.mint)
VStack(alignment: .center, spacing: 25) {
Text("Test text \(viewModel.publishedValue)")
.onChange(of: viewModel.publishedValue) { newValue in
// Change status bar color
if viewModel.publishedValue % 2 == 0 {
self.body.statusBarStyle(.lightContent)
} else {
self.body.statusBarStyle(.darkContent)
}
}
Button("Increment") {
viewModel.publishedValue += 1
}
}
}
.ignoresSafeArea()
.statusBarStyle(.lightContent)
}
}
class TestViewModel: ObservableObject {
#Published var publishedValue: Int
init(publishedValue: Int) {
self.publishedValue = publishedValue
}
}
extension View {
/// Overrides the default status bar style with the given `UIStatusBarStyle`.
///
/// - Parameters:
/// - style: The `UIStatusBarStyle` to be used.
func statusBarStyle(_ style: UIStatusBarStyle) -> some View {
return self.background(HostingWindowFinder(callback: { window in
guard let rootViewController = window?.rootViewController else { return }
let hostingController = HostingViewController(rootViewController: rootViewController, style: style)
window?.rootViewController = hostingController
}))
}
}
fileprivate class HostingViewController: UIViewController {
private var rootViewController: UIViewController?
private var style: UIStatusBarStyle = .default
init(rootViewController: UIViewController, style: UIStatusBarStyle) {
self.rootViewController = rootViewController
self.style = style
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
override func viewDidLoad() {
super.viewDidLoad()
guard let child = rootViewController else { return }
addChild(child)
view.addSubview(child.view)
child.didMove(toParent: self)
}
override var preferredStatusBarStyle: UIStatusBarStyle {
return style
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
setNeedsStatusBarAppearanceUpdate()
}
}
fileprivate struct HostingWindowFinder: UIViewRepresentable {
var callback: (UIWindow?) -> ()
func makeUIView(context: Context) -> UIView {
let view = UIView()
DispatchQueue.main.async { [weak view] in
self.callback(view?.window)
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
// ...
}
}
GitHub repo for the example project: https://github.com/Iikeli/view-modifier-test
You are way overcomplicating this. Since your viewModel is #ObservedObject and the publishedValue is Published, the body of your View will be recalculated automatically every time publishedValue is updated. There's no need for a manual onChange.
You can simply move the logic into the input argument of statusBarStyle.
var body: some View {
ZStack {
Rectangle().foregroundColor(.mint)
VStack(alignment: .center, spacing: 25) {
Text("Test text \(viewModel.publishedValue)")
Button("Increment") {
viewModel.publishedValue += 1
}
}
}
.ignoresSafeArea()
.statusBarStyle(viewModel.publishedValue % 2 == 0 ? .lightContent : .darkContent)
}
Or even better, move the logic into a separate computed property:
var body: some View {
....
.statusBarStyle(statusBarStyle)
}
private var statusBarStyle: UIStatusBarStyle {
viewModel.publishedValue % 2 == 0 ? .lightContent : .darkContent
}
The short answer is no, but that doesn't mean you can't use it to have views change based on some .onChange(..) action. For example.
#State var somethingChanged = false
Text(somethingChanged ? "First Value" : "Second Value")
// Your code/view
.onChange(..) {
//Some Condition or whatever you want.
somethingChanged = true
}
Your usage might look something like this.
content
.foregroundColor(somethingChanged ? .red : .blue)
.onChange(ofPublishedValue) {
somethingChanged = true
}
First of all, thanks for the help. Neither of the answers helped in my situation, since I couldn't get the modifier to update with the variable change. But with some Googling and trying out different solutions I figured out a working solution for updating the status bar colors.
I needed to update the style variable in the HostingViewController, and then update accordingly. So I added the HostingViewController as a #StateObject and updated the style variable inside the .onChange(). Not quite the solution I was going with to start out, but it does work.
The code:
import SwiftUI
import Introspect
struct ContentView: View {
#ObservedObject var viewModel: TestViewModel
#StateObject var hostingViewController: HostingViewController = .init(rootViewController: nil, style: .default)
var body: some View {
ZStack {
Rectangle().foregroundColor(.mint)
VStack(alignment: .center, spacing: 25) {
Text("Test text \(viewModel.publishedValue)")
.onChange(of: viewModel.publishedValue) { newValue in
// Change status bar color
if viewModel.publishedValue % 2 == 0 {
hostingViewController.style = .lightContent
} else {
hostingViewController.style = .darkContent
}
}
Button("Increment") {
viewModel.publishedValue += 1
}
}
}
.ignoresSafeArea()
.introspectViewController { viewController in
let window = viewController.view.window
guard let rootViewController = window?.rootViewController else { return }
hostingViewController.rootViewController = rootViewController
window?.rootViewController = hostingViewController
}
}
}
class TestViewModel: ObservableObject {
#Published var publishedValue: Int
init(publishedValue: Int) {
self.publishedValue = publishedValue
}
}
class HostingViewController: UIViewController, ObservableObject {
var rootViewController: UIViewController?
var style: UIStatusBarStyle = .default {
didSet {
self.rootViewController?.setNeedsStatusBarAppearanceUpdate()
}
}
init(rootViewController: UIViewController?, style: UIStatusBarStyle) {
self.rootViewController = rootViewController
self.style = style
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
override func viewDidLoad() {
super.viewDidLoad()
guard let child = rootViewController else { return }
addChild(child)
view.addSubview(child.view)
child.didMove(toParent: self)
}
override var preferredStatusBarStyle: UIStatusBarStyle {
return style
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
setNeedsStatusBarAppearanceUpdate()
}
}
Big shoutout to this answer for giving me all that I needed to implement the solution. Note: You can override the rootViewController however you like, I used SwiftUI-Introspect as we are already using it in our project anyway.
GitHub branch
Problem
Hello. I'm studying SwiftUI.
I've tried to pick multiple photos from gallery using PHPickerController, and show up multiple views which represents each photo one by one. However, Fatal error occurs whenever I try to access any index of vm.images.
How could I solve this issue?
My source code is as follows
Solved
The problem comes from vm.images I thought that .onChange modifier operate after all images are saved into vm.images. But it didn't.
I solved this matter by adding if statement when calling PickerTabView; Quite Easy
// Added code
if let images = vm.images {
if images.count > 0 {
PickerTabView()
}
}
struct PickerTabView: View {
#EnvironmentObject var vm: ViewModel
var body: some View {
TabView {
if let images = vm.images{
ForEach(images, id: \.self) { image in
PickerSettingView(image: image)
}
}
}
.tabViewStyle(.page)
}
}
struct ImagesPicker: UIViewControllerRepresentable {
#Binding var selectedImages: [UIImage]?
//var selectionLimit: Int
//var filter: PHPickerFilter?
var itemProvider: [NSItemProvider] = []
func makeUIViewController(context: Context) -> some PHPickerViewController {
var configuration = PHPickerConfiguration()
configuration.selectionLimit = 20
configuration.filter = .images
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
}
func makeCoordinator() -> Coordinator {
return ImagesPicker.Coordinator(parent: self)
}
class Coordinator: NSObject, PHPickerViewControllerDelegate, UINavigationControllerDelegate {
var parent: ImagesPicker
init(parent: ImagesPicker) {
self.parent = parent
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
if !results.isEmpty {
parent.itemProvider = []
parent.selectedImages = []
}
parent.itemProvider = results.map(\.itemProvider)
loadImage()
}
private func loadImage() {
for itemProvider in parent.itemProvider {
if itemProvider.canLoadObject(ofClass: UIImage.self) {
itemProvider.loadObject(ofClass: UIImage.self) { image, error in
DispatchQueue.main.sync {
if let image = image as? UIImage {
self.parent.selectedImages?.append(image)
}
}
}
}
}
}
}
}
struct PickerHomeView: View {
#EnvironmentObject var vm: ViewModel
#State private var isSelected = false
var body: some View {
NavigationView {
VStack {
NavigationLink("Tab View", isActive: $isSelected) {
PickerTabView()
}
.hidden()
HStack {
Button {
vm.showPicker()
} label: {
ButtonLabel(symbolName: "photo.fill", label: "Photos")
}
}
Spacer()
}
.sheet(isPresented: $vm.showPicker) {
ImagesPicker(selectedImages: $vm.images)
.ignoresSafeArea()
}
.onChange(of: vm.images, perform: { _ in
isSelected = true
})
}
}
}
struct PickerSettingView: View {
#EnvironmentObject var vm: ViewModel
var image: UIImage
let myImage = MyImage(category: Category.unCategorized)
#State private var selectedCategory: Category = Category.unCategorized
var body: some View {
VStack {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(minWidth: 0, maxWidth:.infinity)
SwiftUI.Picker("Category Picker", selection: $selectedCategory) {
Text("Formal").tag(Category.formal)
Text("Casual").tag(Category.casual)
Text("Semi Formal").tag(Category.semiFormal)
}
.pickerStyle(.segmented)
.padding([.leading, .trailing], 16)
HStack {
Button {
if vm.selectedImage == nil {
vm.addMyImage(category: selectedCategory, image: image)
} else {
vm.updateSelected()
}
} label: {
ButtonLabel(symbolName: vm.selectedImage == nil ? "square.and.arrow.down.fill" :
"square.and.arrow.up.fill",
label: vm.selectedImage == nil ? "Save" : "Update")
}
}
}
}
}
class ViewModel: ObservableObject {
#Published var images: [UIImage]?
#Published var showPicker = false
}
I think the image in your loadImage, is not being added to the self.parent.selectedImages
when it is nil, that is, when images in your ViewModel is nil, as it is at the start. So whenever you try to access any index of the images array in your vm.images, the app crashes.
You could try this in your loadImage (note also .async) to append the images:
DispatchQueue.main.async {
if let image = image as? UIImage {
if self.parent.selectedImages == nil { self.parent.selectedImages = [] }
self.parent.selectedImages!.append(image)
}
}
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)
}
}
I'm trying to have every object in the view be shifted up when a text field is present, and to reduce amount of code, I tried making the modifiers into a modifier class, but the issue is when the y value gets changed for the keyboard handlers, it doesn't move. This exact code works if you don't use the separate function, so I don't know what's wrong.
I've tried making the value of the y a state in the modifier, but it still doesn't update - and like I said, it works if it's not in the custom modifier.
ContentView.swift
struct ContentView: View {
#State private var name = Array<String>.init(repeating: "", count: 3)
#ObservedObject private var kGuardian = KeyboardGuardian(textFieldCount: 1)
var body: some View {
Background {
VStack {
Group {
Text("Some filler text").font(.largeTitle)
Text("Some filler text").font(.largeTitle)
}
TextField("enter text #1", text: self.$name[0])
.textFieldStyle(RoundedBorderTextFieldStyle())
TextField("enter text #2", text: self.$name[1])
.textFieldStyle(RoundedBorderTextFieldStyle())
TextField("enter text #3", text: self.$name[2])
.textFieldStyle(RoundedBorderTextFieldStyle())
.background(GeometryGetter(rect: self.$kGuardian.rects[0]))
}
}.modifier(BetterTextField(kGuardian: kGuardian, slide: kGuardian.slide))
}
}
KeyboardModifications.swift
struct GeometryGetter: View {
#Binding var rect: CGRect
var body: some View {
GeometryReader { geometry in
Group { () -> AnyView in
DispatchQueue.main.async {
self.rect = geometry.frame(in: .global)
}
return AnyView(Color.clear)
}
}
}
}
final class KeyboardGuardian: ObservableObject {
public var rects: Array<CGRect>
public var keyboardRect: CGRect = CGRect()
// keyboardWillShow notification may be posted repeatedly,
// this flag makes sure we only act once per keyboard appearance
public var keyboardIsHidden = true
#Published var slide: CGFloat = 0
var appearenceDuration : Double = 0
var showField: Int = 0 {
didSet {
updateSlide()
}
}
init(textFieldCount: Int) {
self.rects = Array<CGRect>(repeating: CGRect(), count: textFieldCount)
NotificationCenter.default.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyBoardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
#objc func keyBoardWillShow(notification: Notification) {
guard let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else {return}
appearenceDuration = duration
if keyboardIsHidden {
keyboardIsHidden = false
if let rect = notification.userInfo?["UIKeyboardFrameEndUserInfoKey"] as? CGRect {
keyboardRect = rect
updateSlide()
}
}
}
#objc func keyBoardWillHide(notification: Notification) {
keyboardIsHidden = true
updateSlide()
}
func updateSlide() {
if keyboardIsHidden {
slide = 0
} else {
let tfRect = self.rects[self.showField]
let diff = keyboardRect.minY - tfRect.maxY
if diff > 0 {
slide += diff
} else {
slide += min(diff, 0)
}
}
}
}
struct Background<Content: View>: View {
private var content: Content
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content()
}
var body: some View {
Color.white
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
.overlay(content)
}
}
The custom modifier class:
struct BetterTextField: ViewModifier {
public var kGuardian : KeyboardGuardian
#State public var slide : CGFloat
func body(content: Content) -> some View {
content
.offset(y: slide).animation(.easeIn(duration: kGuardian.appearenceDuration))
.onTapGesture {
let keyWindow = UIApplication.shared.connectedScenes
.filter({$0.activationState == .foregroundActive})
.map({$0 as? UIWindowScene})
.compactMap({$0})
.first?.windows
.filter({$0.isKeyWindow}).first
keyWindow!.endEditing(true)
}
}
}
What should happen is the entire screen should shift up with the keyboard, but it stays as it is. I've been trying things for around 2 hours to no avail.
I guess your slide variable is not updated. Try with the following change code in BetterTextField modifier:
struct BetterTextField: ViewModifier {
#ObservedObject var kGuardian : KeyboardGuardian
func body(content: Content) -> some View {
content
.offset(y: kGuardian.slide).animation(.easeIn(duration: kGuardian.appearenceDuration))
.onTapGesture {
let keyWindow = UIApplication.shared.connectedScenes
.filter({$0.activationState == .foregroundActive})
.map({$0 as? UIWindowScene})
.compactMap({$0})
.first?.windows
.filter({$0.isKeyWindow}).first
keyWindow!.endEditing(true)
}
}
}