How to properly update a modifier? - swift

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

Related

Marquee with buttons inside a view SwiftUI

I'm trying to have a marquee horizontal scrolling effect but my buttons are not clickable. The view renders well, but when I tap the buttons it should print out 'tapped user1', for example but there is no effect.
EDIT: If I put the marquee modifier on the ScrollView as suggested, it causes the extremely buggy scrolling behavior. Ideally, this would just be a marquee'd HStack with a bunch of clickable buttons in it with no scrolling behavior built in, but the module doesn't seem to work without the ScrollView wrapping it.
I used this link to create a Marquee view modifier: https://swiftuirecipes.com/blog/swiftui-marquee
My code for the view is below:
struct MyView: View {
var body: some View {
let users = ["user1", "user2", "user3", "user4", "user5", "user6"]
return ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(users, id: \.self) { user in
if user == users.first {
Button(action: {
print("tapped \(user)")
}, label: {
Text(user)
})
} else {
Text("•")
Button(action: {
print("tapped \(user)")
}, label: {
Text(user)
})
}
}
}.frame(height: 20)
.marquee(duration: 10)
}
}
}
The code from the marquee tutorial is below:
struct Marquee: ViewModifier {
let duration: TimeInterval
let direction: Direction
let autoreverse: Bool
#State private var offset = CGFloat.zero
#State private var parentSize = CGSize.zero
#State private var contentSize = CGSize.zero
func body(content: Content) -> some View {
// measures parent view width
Color.clear
.frame(height: 0)
// measureSize from https://swiftuirecipes.com/blog/getting-size-of-a-view-in-swiftui
.measureSize { size in
parentSize = size
updateAnimation(sizeChanged: true)
}
content
.measureSize { size in
contentSize = size
updateAnimation(sizeChanged: true)
}
.offset(x: offset)
// animationObserver from https://swiftuirecipes.com/blog/swiftui-animation-observer
.animationObserver(for: offset, onComplete: {
updateAnimation(sizeChanged: false)
})
}
private func updateAnimation(sizeChanged: Bool) {
if sizeChanged || !autoreverse {
offset = max(parentSize.width, contentSize.width) * ((direction == .leftToRight) ? -1 : 1)
}
withAnimation(.linear(duration: duration)) {
offset = -offset
}
}
enum Direction {
case leftToRight, rightToLeft
}
}
extension View {
func marquee(duration: TimeInterval,
direction: Marquee.Direction = .rightToLeft,
autoreverse: Bool = false) -> some View {
self.modifier(Marquee(duration: duration,
direction: direction,
autoreverse: autoreverse))
}
}
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
struct MeasureSizeModifier: ViewModifier {
func body(content: Content) -> some View {
content.background(GeometryReader { geometry in
Color.clear.preference(key: SizePreferenceKey.self,
value: geometry.size)
})
}
}
extension View {
func measureSize(perform action: #escaping (CGSize) -> Void) -> some View {
self.modifier(MeasureSizeModifier())
.onPreferenceChange(SizePreferenceKey.self, perform: action)
}
}
public struct AnimationObserverModifier<Value: VectorArithmetic>: AnimatableModifier {
// this is the view property that drives the animation - offset, opacity, etc.
private let observedValue: Value
private let onChange: ((Value) -> Void)?
private let onComplete: (() -> Void)?
// SwiftUI implicity sets this value as the animation progresses
public var animatableData: Value {
didSet {
notifyProgress()
}
}
public init(for observedValue: Value,
onChange: ((Value) -> Void)?,
onComplete: (() -> Void)?) {
self.observedValue = observedValue
self.onChange = onChange
self.onComplete = onComplete
animatableData = observedValue
}
public func body(content: Content) -> some View {
content
}
private func notifyProgress() {
DispatchQueue.main.async {
onChange?(animatableData)
if animatableData == observedValue {
onComplete?()
}
}
}
}
public extension View {
func animationObserver<Value: VectorArithmetic>(for value: Value,
onChange: ((Value) -> Void)? = nil,
onComplete: (() -> Void)? = nil) -> some View {
self.modifier(AnimationObserverModifier(for: value,
onChange: onChange,
onComplete: onComplete))
}
}
Put the modifier on scrollview, it will fix your issue
Like this.
import SwiftUI
struct MyView: View {
var body: some View {
let users = ["user1", "user2", "user3", "user4", "user5", "user6"]
return ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(users, id: \.self) { user in
if user == users.first {
Button(action: {
print("tapped \(user)")
}, label: {
Text(user)
})
} else {
Text("•")
Button(action: {
print("tapped \(user)")
}, label: {
Text(user)
})
}
}
}.frame(height: 20)
}
.marquee(duration: 10)
}
}
Try adding the modifier on the button .buttonStyle(PlainButtonStyle()) or similar with other button styles. If that doesn't work, then try using just the button label that you desire and add the modifier .onTapGesture {your code}

Banner view does not update when #State variable changes

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

Array Indexing occurs Fatal error: Index out of range

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

Retrieving keyboard height when textfield editing starts in SwiftUI

So I have a class with a published variable called keyboardHeight that is used to retrieve the value of the keyboardHeight:
import UIKit
class KeyboardHeightHelper: ObservableObject {
#Published var keyboardHeight: CGFloat = 0
init() {
self.listenForKeyboardNotifications()
}
private func listenForKeyboardNotifications() {
NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidShowNotification,
object: nil,
queue: .main) { (notification) in
guard let userInfo = notification.userInfo,
let keyboardRect = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
self.keyboardHeight = keyboardRect.height
}
NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidHideNotification,
object: nil,
queue: .main) { (notification) in
self.keyboardHeight = 0
}
}
}
Then, in ContentView I just have a TextField that should print the keyboard height when you start/stop editing the field:
import SwiftUI
struct ContentView: View {
#State var textFieldText = ""
#ObservedObject var keyboardHeightHelper = KeyboardHeightHelper()
var body: some View {
VStack {
TextField("Text field",
text: $textFieldText, onEditingChanged: { _ in print("the keyboard height is \(self.keyboardHeightHelper.keyboardHeight)") })
}
}
}
The problem I have is this: When I am not editing the textfield and then click it, it prints the keyboard height is 0.0 (I guess this is because it grabs the keyboardHeight value before it presents the keyboard, so at the time the height is 0.0 as the keyboard isn't seen). When I press return and the keyboard is dismissed, the height of the keyboard (for the iPhone 8 simulator) is printed as the correct value of 260.0. My question is how do I access the value of the keyboard when I start editing?
Try this. You can integrate it using just a modifier to SwiftUI view:
extension UIResponder {
static var currentFirstResponder: UIResponder? {
_currentFirstResponder = nil
UIApplication.shared.sendAction(#selector(UIResponder.findFirstResponder(_:)), to: nil, from: nil, for: nil)
return _currentFirstResponder
}
private static weak var _currentFirstResponder: UIResponder?
#objc private func findFirstResponder(_ sender: Any) {
UIResponder._currentFirstResponder = self
}
var globalFrame: CGRect? {
guard let view = self as? UIView else { return nil }
return view.superview?.convert(view.frame, to: nil)
}
}
extension Publishers {
static var keyboardHeight: AnyPublisher<CGFloat, Never> {
let willShow = NotificationCenter.default.publisher(for: UIApplication.keyboardWillShowNotification)
.map { $0.keyboardHeight }
let willHide = NotificationCenter.default.publisher(for: UIApplication.keyboardWillHideNotification)
.map { _ in CGFloat(0) }
return MergeMany(willShow, willHide)
.eraseToAnyPublisher()
}
}
extension Notification {
var keyboardHeight: CGFloat {
return (userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.height ?? 0
}
}
struct KeyboardAdaptive: ViewModifier {
#State private var bottomPadding: CGFloat = 0
func body(content: Content) -> some View {
GeometryReader { geometry in
content
.padding(.bottom, self.bottomPadding)
.onReceive(Publishers.keyboardHeight) { keyboardHeight in
let keyboardTop = geometry.frame(in: .global).height - keyboardHeight
let focusedTextInputBottom = UIResponder.currentFirstResponder?.globalFrame?.maxY ?? 0
self.bottomPadding = max(0, focusedTextInputBottom - keyboardTop - geometry.safeAreaInsets.bottom)
}
.animation(.easeOut(duration: 0.16))
}
}
}
extension View {
func keyboardAdaptive() -> some View {
ModifiedContent(content: self, modifier: KeyboardAdaptive())
}
}
Usage:
struct ContentView: View {
#State private var text = ""
var body: some View {
VStack {
Spacer()
TextField("Enter something", text: $text)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
.padding()
.keyboardAdaptive()//note this
}
}
Credits to multiple online sources.
The .onEditingChanged is not appropriate place to read keyboard height, because you receive this callback right in the moment of click in TextField, so there is no keyboard yet shown (and, so, no notification received).
Instead you can listen explicitly for your keyboardHeight property publisher and be notified exactly when it is changed (what is performed on keyboard notifications synchronously, so in time)
Here is a solution (tested with Xcode 12 / iOS 14)
VStack {
TextField("Text field",
text: $textFieldText, onEditingChanged: { _ in })
.onReceive(keyboardHeightHelper.$keyboardHeight) { value in
print("the keyboard height is \(value)")
}
}

SwiftUI: Pop to root view when selected tab is tapped again

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