I have a type that vends a Timer.TimerPublisher, which you can see below:
import Combine
import Foundation
struct TimerClient {
// MARK: Properties
var timerValueChange: () -> AnyPublisher<Date, Never>
// MARK: Initialization
init(
timerValueChange: #escaping () -> AnyPublisher<Date, Never>
) {
self.timerValueChange = timerValueChange
}
}
extension TimerClient {
// MARK: Properties
static var live: Self {
Self {
return Timer
.TimerPublisher(
interval: 1,
runLoop: .main,
mode: .common
)
.autoconnect()
.share()
.eraseToAnyPublisher()
}
}
}
I have a View that is used to save new events. It is injected with a view model that uses TimerClient so that I can disable the Save button if users attempt to save an event with a past date:
import Combine
final class CountdownEventEntryViewModel: ObservableObject {
// MARK: Properties
private let nowSubject = CurrentValueSubject<Date, Never>(Date())
private let calendar: Calendar
private let timerClient: TimerClient
private var cancellables = Set<AnyCancellable>()
#Published var eventTitle = ""
#Published var isAllDay = false
#Published var eventDate = Date()
#Published var eventTime = Date()
#Published var shouldDisableSaveButton = true
// MARK: Initialization
init(
calendar: Calendar = .autoupdatingCurrent,
timerClient: TimerClient
) {
self.calendar = calendar
self.timerClient = timerClient
observeTimer()
observeCurrentDateChanges()
}
// MARK: UI Configuration
private func disableSaveButton() -> Bool {
if eventTitle.trimmingCharacters(in: .whitespaces).isEmpty {
return true
}
if isAllDay {
let startOfToday = calendar.startOfDay(for: nowSubject.value)
let startOfSelectedDate = calendar.startOfDay(for: eventDate)
return startOfSelectedDate <= startOfToday
} else {
return normalizedSelectedDate() <= nowSubject.value
}
}
private func observeTimer() {
timerClient.timerValueChange().sink(receiveValue: { [weak self] newDate in
self?.nowSubject.send(newDate)
})
.store(in: &cancellables)
}
private func observeCurrentDateChanges() {
nowSubject.sink(receiveValue: { [weak self] _ in
self?.shouldDisableSaveButton = self?.disableSaveButton() ?? false
})
.store(in: &cancellables)
}
}
The view model works well, and it updates the Save button if an attempt is made to select a past date:
import SwiftUI
struct CountdownEventEntryView: View {
// MARK: Properties
#ObservedObject var viewModel: CountdownEventEntryViewModel
var body: some View {
Form {
Section {
TextField(
viewModel.eventTitlePlaceholderKey,
text: $viewModel.eventTitle
)
}
Section {
Toggle(
isOn: $viewModel.isAllDay.animation(),
label: {
Text(viewModel.isAllDayLabelTextKey)
}
)
DatePicker(
viewModel.eventDatePickerLabelTextKey,
selection: $viewModel.eventDate,
displayedComponents: [.date]
)
if !viewModel.isAllDay {
DatePicker(
viewModel.eventTimePickerLabelTextKey,
selection: $viewModel.eventTime,
displayedComponents: [.hourAndMinute]
)
}
}
}
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button {
presentationMode.wrappedValue.dismiss()
} label: {
Text(viewModel.saveButtonTitleKey)
}
.disabled(viewModel.shouldDisableSaveButton)
}
}
}
}
After the entry view is dismissed, the List that displays saved events is updated according to the following view model:
import Combine
import CoreData
final class CountdownEventsViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDelegate {
// MARK: Properties
private let calendar: Calendar
private let timerClient: TimerClient
private var cancellables = Set<AnyCancellable>()
#Published var now = Date()
#Published var countdownEvents = [CountdownEvent]()
// MARK: Initialization
init(
calendar: Calendar = .autoupdatingCurrent,
timerClient: TimerClient
) {
self.calendar = calendar
self.timerClient = timerClient
super.init()
observeTimer()
fetchCountdownEvents()
}
// MARK: Timer Observation
private func observeTimer() {
timerClient.timerValueChange().sink(receiveValue: { [weak self] newDate in
self?.now = newDate
})
.store(in: &cancellables)
}
// MARK: Event Display
func formattedTitle(for event: CountdownEvent) -> String {
event.title ?? untitledLabelKey
}
func formattedDate(for event: CountdownEvent) -> String {
guard let date = event.date else {
return dateUnknownLabelKey
}
if event.isAllDay {
return DateFormatter.dateOnlyFormatter.string(from: date)
}
return DateFormatter.dateAndTimeFormatter.string(from: date)
}
func formattedTimeRemaining(from date: Date, to event: CountdownEvent) -> String {
guard let eventDate = event.date else {
return dateUnknownLabelKey
}
let allowedComponents: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second]
let dateComponents = calendar.dateComponents(allowedComponents, from: date, to: eventDate)
guard let formatted = DateComponentsFormatter.eventTimeRemainingFormatter.string(from: dateComponents) else {
return dateUnknownLabelKey
}
return formatted
}
}
The view model is used in a View that displays all saved items in a list:
import SwiftUI
struct CountdownEventsView: View {
// MARK: Properties
#ObservedObject private var viewModel: CountdownEventsViewModel
#State private var showEventEntry = false
#State private var now = Date()
var body: some View {
List {
Section {
ForEach(viewModel.countdownEvents) { event in
VStack(alignment: .leading) {
Text(viewModel.formattedTitle(for: event))
Text(viewModel.formattedDate(for: event))
Text(viewModel.formattedTimeRemaining(from: now, to: event))
}
}
}
}
.toolbar {
ToolbarItem(placement: .navigation) {
HStack(spacing: 30) {
Button(action: showSettings) {
Label(viewModel.settingsButtonTitleKey, systemImage: viewModel.settingsButtonImageName)
}
Button(action: {
showEventEntry = true
}, label: {
Label(
title: { Text(viewModel.addEventButtonTitleKey) },
icon: { Image(systemName: viewModel.addEventButtonImageName) }
)
})
}
}
.sheet(
isPresented: $showEventEntry,
content: {
NavigationView {
CountdownEventEntryView(
viewModel:
CountdownEventEntryViewModel(
timerClient: .live
)
)
}
}
)
.onReceive(viewModel.$now, perform: { now in
self.now = now
})
}
// MARK: Initialization
init(viewModel: CountdownEventsViewModel) {
self.viewModel = viewModel
}
}
This works as expected, and the Text elements are updated with the expected values. Additionally, I am able to scroll the List and see the Text values update thanks to adding the Timer to the main runloop and common mode. However, when I navigate to the event-entry view, the view model seems to get reset when the timer fires.
Below, you can see that the entered text is reset with every fire of the timer:
It seems that my view model is somehow being recreated, but both view models are #ObservableObjects, so I am not sure why I'm seeing the values reset after the timer fires. The DatePicker, Toggle, TextField, and Button values are all reset to their defaults when the timer fires.
What am I missing that is causing the text to clear when the timer fires?
If it helps, the project is located here. Be sure to use the save-countdown-events branch. Additionally, I am modeling my client types after Point-Free's teachings about dependency injection (code), if that helps provide more context.
Related
Struggling to get a simple example up and running in swiftui:
Load default list view (working)
click button that launches picker/filtering options (working)
select options, then click button to dismiss and call function with selected options (call is working)
display new list of objects returned from call (not working)
I'm stuck on #4 where the returned query isn't making it to the view. I suspect I'm creating a different instance when making the call in step #3 but it's not making sense to me where/how/why that matters.
I tried to simplify the code some, but it's still a bit, sorry for that.
Appreciate any help!
Main View with HStack and button to filter with:
import SwiftUI
import FirebaseFirestore
struct TestView: View {
#ObservedObject var query = Query()
#State var showMonPicker = false
#State var monFilter = "filter"
var body: some View {
VStack {
HStack(alignment: .center) {
Text("Monday")
Spacer()
Button(action: {
self.showMonPicker.toggle()
}, label: {
Text("\(monFilter)")
})
}
.padding()
ScrollView(.horizontal) {
LazyHStack(spacing: 35) {
ForEach(query.queriedList) { menuItems in
MenuItemView(menuItem: menuItems)
}
}
}
}
.sheet(isPresented: $showMonPicker, onDismiss: {
//optional function when picker dismissed
}, content: {
CuisineTypePicker(selectedCuisineType: $monFilter)
})
}
}
The Query() file that calls a base query with all results, and optional function to return specific results:
import Foundation
import FirebaseFirestore
class Query: ObservableObject {
#Published var queriedList: [MenuItem] = []
init() {
baseQuery()
}
func baseQuery() {
let queryRef = Firestore.firestore().collection("menuItems").limit(to: 50)
queryRef
.getDocuments() { (querySnapshot, err) in
if let err = err {
print("Error getting documents: \(err)")
} else {
self.queriedList = querySnapshot?.documents.compactMap { document in
try? document.data(as: MenuItem.self)
} ?? []
}
}
}
func filteredQuery(category: String?, glutenFree: Bool?) {
var filtered = Firestore.firestore().collection("menuItems").limit(to: 50)
// Sorting and Filtering Data
if let category = category, !category.isEmpty {
filtered = filtered.whereField("cuisineType", isEqualTo: category)
}
if let glutenFree = glutenFree, !glutenFree {
filtered = filtered.whereField("glutenFree", isEqualTo: true)
}
filtered
.getDocuments() { (querySnapshot, err) in
if let err = err {
print("Error getting documents: \(err)")
} else {
self.queriedList = querySnapshot?.documents.compactMap { document in
try? document.data(as: MenuItem.self);
} ?? []
print(self.queriedList.count)
}
}
}
}
Picker view where I'm calling the filtered query:
import SwiftUI
struct CuisineTypePicker: View {
#State private var cuisineTypes = ["filter", "American", "Chinese", "French"]
#Environment(\.presentationMode) var presentationMode
#Binding var selectedCuisineType: String
#State var gfSelected = false
let query = Query()
var body: some View {
VStack(alignment: .center) {
//Buttons and formatting code removed to simplify..
}
.padding(.top)
Picker("", selection: $selectedCuisineType) {
ForEach(cuisineTypes, id: \.self) {
Text($0)
}
}
Spacer()
Button(action: {
self.query.filteredQuery(category: selectedCuisineType, glutenFree: gfSelected)
self.presentationMode.wrappedValue.dismiss()
}, label: {
Text( "apply filters")
})
}
.padding()
}
}
I suspect that the issue stems from the fact that you aren't sharing the same instance of Query between your TestView and your CuisineTypePicker. So, when you start a new Firebase query on the instance contained in CuisineTypePicker, the results are never reflected in the main view.
Here's an example of how to solve that (with the Firebase code replaced with some non-asynchronous sample code for now):
struct MenuItem : Identifiable {
var id = UUID()
var cuisineType : String
var title : String
var glutenFree : Bool
}
struct ContentView: View {
#ObservedObject var query = Query()
#State var showMonPicker = false
#State var monFilter = "filter"
var body: some View {
VStack {
HStack(alignment: .center) {
Text("Monday")
Spacer()
Button(action: {
self.showMonPicker.toggle()
}, label: {
Text("\(monFilter)")
})
}
.padding()
ScrollView(.horizontal) {
LazyHStack(spacing: 35) {
ForEach(query.queriedList) { menuItem in
Text("\(menuItem.title) - \(menuItem.cuisineType)")
}
}
}
}
.sheet(isPresented: $showMonPicker, onDismiss: {
//optional function when picker dismissed
}, content: {
CuisineTypePicker(query: query, selectedCuisineType: $monFilter)
})
}
}
class Query: ObservableObject {
#Published var queriedList: [MenuItem] = []
private let allItems: [MenuItem] = [.init(cuisineType: "American", title: "Hamburger", glutenFree: false),.init(cuisineType: "Chinese", title: "Fried Rice", glutenFree: true)]
init() {
baseQuery()
}
func baseQuery() {
self.queriedList = allItems
}
func filteredQuery(category: String?, glutenFree: Bool?) {
queriedList = allItems.filter({ item in
if let category = category {
return item.cuisineType == category
} else {
return true
}
}).filter({item in
if let glutenFree = glutenFree {
return item.glutenFree == glutenFree
} else {
return true
}
})
}
}
struct CuisineTypePicker: View {
#ObservedObject var query : Query
#Binding var selectedCuisineType: String
#State private var gfSelected = false
private let cuisineTypes = ["filter", "American", "Chinese", "French"]
#Environment(\.presentationMode) private var presentationMode
var body: some View {
VStack(alignment: .center) {
//Buttons and formatting code removed to simplify..
}
.padding(.top)
Picker("", selection: $selectedCuisineType) {
ForEach(cuisineTypes, id: \.self) {
Text($0)
}
}
Spacer()
Button(action: {
self.query.filteredQuery(category: selectedCuisineType, glutenFree: gfSelected)
self.presentationMode.wrappedValue.dismiss()
}, label: {
Text( "apply filters")
})
}
}
I made a view which fetches and shows a list of data. There is a context menu in toolbar where user can change data categories. This context menu lives outside of the list.
What I want to do is when user selects a category, the list should refetch data from backend and redraw entire of the view.
I made a BaseListView which can be reused in various screens in my app, and since the loadData is inside the BaseListView, I don't know how to invoke it to reload data.
Did I do this with good approaching? Is there any way to force SwiftUI recreates entire of view so that the BaseListView loads data & renders subviews as first time it's created?
struct ProductListView: View {
var body: some View {
BaseListView(
rowView: { ProductRowView(product: $0, searchText: $1)},
destView: { ProductDetailsView(product: $0) },
dataProvider: {(pageIndex, searchText, complete) in
return fetchProducts(pageIndex, searchText, complete)
})
.hideKeyboardOnDrag()
.toolbar {
ProductCategories()
}
.onReceive(self.userSettings.$selectedCategory) { category in
//TODO: Here I need to reload data & recreate entire of view.
}
.navigationTitle("Products")
}
}
extension ProductListView{
private func fetchProducts(_ pageIndex: Int,_ searchText: String, _ complete: #escaping ([Product], Bool) -> Void) -> AnyCancellable {
let accountId = Defaults.selectedAccountId ?? ""
let pageSize = 20
let query = AllProductsQuery(id: accountId,
pageIndex: pageIndex,
pageSize: pageSize,
search: searchText)
return Network.shared.client.fetchPublisher(query: query)
.sink{ completion in
switch completion {
case .failure(let error):
print(error)
case .finished:
print("Success")
}
} receiveValue: { response in
if let data = response.data?.getAllProducts{
let canLoadMore = (data.count ?? 0) > pageSize * pageIndex
let rows = data.rows
complete(rows, canLoadMore)
}
}
}
}
ProductCategory is a separated view:
struct ProductCategories: View {
#EnvironmentObject var userSettings: UserSettings
var categories = ["F&B", "Beauty", "Auto"]
var body: some View{
Menu {
ForEach(categories,id: \.self){ item in
Button(item, action: {
userSettings.selectedCategory = item
Defaults.selectedCategory = item
})
}
}
label: {
Text(self.userSettings.selectedCategory ?? "All")
.regularText()
.autocapitalization(.words)
.frame(maxWidth: .infinity)
}.onAppear {
userSettings.selectedCategory = Defaults.selectedCategory
}
}
}
Since my app has various list-view with same behaviours (Pagination, search, ...), I make a BaseListView like this:
struct BaseListView<RowData: StringComparable & Identifiable, RowView: View, Target: View>: View {
enum ListState {
case loading
case loadingMore
case loaded
case error(Error)
}
typealias DataCallback = ([RowData],_ canLoadMore: Bool) -> Void
#State var rows: [RowData] = Array()
#State var state: ListState = .loading
#State var searchText: String = ""
#State var pageIndex = 1
#State var canLoadMore = true
#State var cancellableSet = Set<AnyCancellable>()
#ObservedObject var searchBar = SearchBar()
#State var isLoading = false
let rowView: (RowData, String) -> RowView
let destView: (RowData) -> Target
let dataProvider: (_ page: Int,_ search: String, _ complete: #escaping DataCallback) -> AnyCancellable
var searchable: Bool?
var body: some View {
HStack{
content
}
.if(searchable != false){view in
view.add(searchBar)
}
.hideKeyboardOnDrag()
.onAppear(){
print("On appear")
searchBar.$text
.debounce(for: 0.8, scheduler: RunLoop.main)
.removeDuplicates()
.sink { text in
print("Search bar updated")
self.state = .loading
self.pageIndex = 1
self.searchText = text
self.rows.removeAll()
self.loadData()
}.store(in: &cancellableSet)
}
}
private var content: some View{
switch state {
case .loading:
return Spinner(isAnimating: true, style: .large).eraseToAnyView()
case .error(let error):
print(error)
return Text("Unable to load data").eraseToAnyView()
case .loaded, .loadingMore:
return
ScrollView{
list(of: rows)
}
.eraseToAnyView()
}
}
private func list(of data: [RowData])-> some View{
LazyVStack{
let filteredData = rows.filter({
searchText.isEmpty || $0.contains(string: searchText)
})
ForEach(filteredData){ dataItem in
VStack{
//Row content:
if let target = destView(dataItem), !(target is EmptyView){
NavigationLink(destination: target){
row(dataItem)
}
}else{
row(dataItem)
}
//LoadingMore indicator
if case ListState.loadingMore = self.state{
if self.rows.isLastItem(dataItem){
Seperator(color: .gray)
LoadingView(withText: "Loading...")
}
}
}
}
}
}
private func row(_ dataItem: RowData) -> some View{
rowView(dataItem, searchText).onAppear(){
//Check if need to load next page of data
if rows.isLastItem(dataItem) && canLoadMore && !isLoading{
isLoading = true
state = .loadingMore
pageIndex += 1
print("Load page \(pageIndex)")
loadData()
}
}.padding(.horizontal)
}
private func loadData(){
dataProvider(pageIndex, searchText){ newData, canLoadMore in
self.state = .loaded
rows.append(contentsOf: newData)
self.canLoadMore = canLoadMore
isLoading = false
}
.store(in: &cancellableSet)
}
}
In your BaseListView you should have an onChange modifier that catches changes to userSettings.$selectedCategory and calls loadData there.
If you don't have access to userSettings in BaseListView, pass it in as a Binding or #EnvironmentObject.
[Edit]
I should point out that I am collecting data from a large number of sensors and having to pollute the view model, that is orchestrating this, with lots of #Published and subscriber code gets quite tedious and error prone.
I've also edited the code to be more representative of the actual problem.
[Original]
I'm trying to reduce the amount of code needed to observer a result from another class when using publishers. I would prefer to publish the result from a class that is generating the result instead of having to propagate it back to the calling class.
Here is a simple playground example showing the issue.
import SwiftUI
import Combine
class AnObservableObject: ObservableObject {
#Published var flag: Bool = false
private var timerPub: Publishers.Autoconnect<Timer.TimerPublisher>
private var timerSub: AnyCancellable?
init() {
timerPub = Timer.publish(every: 1, on: .current, in: .common)
.autoconnect()
}
func start() {
timerSub = timerPub.sink { [self] _ in
toggleFlag()
}
}
func stop() {
timerSub?.cancel()
}
func toggleFlag() {
flag.toggle()
}
}
class AnotherObservableObject: ObservableObject {
let ao = AnObservableObject()
func start() {
ao.start()
}
func stop() {
ao.stop()
}
}
struct MyView: View {
#StateObject var ao = AnotherObservableObject()
var body: some View {
VStack {
if ao.ao.flag {
Image(systemName: "flag").foregroundColor(.green)
}
HStack {
Button(action: {ao.start()}, label: {
Text("Toggle Flag")
})
Button(action: {ao.stop()}, label: {
Text("Stop")
})
}
}
.padding()
}
}
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
// Make a UIHostingController
let viewController = UIHostingController(rootView: MyView())
// Assign it to the playground's liveView
PlaygroundPage.current.liveView = viewController
let myView = MyView()
The Only way I have got this to work is to do this:
import SwiftUI
import Combine
class AnObservableObject: ObservableObject {
let flag = CurrentValueSubject<Bool, Never>(false)
private var subscriptions = Set<AnyCancellable>()
private var timerPub: Publishers.Autoconnect<Timer.TimerPublisher>
private var timerSub: AnyCancellable?
init() {
timerPub = Timer.publish(every: 1, on: .current, in: .common)
.autoconnect()
}
func start() {
timerSub = timerPub.sink { [self] _ in
toggleFlag()
}
}
func stop() {
timerSub?.cancel()
}
func flagPublisher() -> AnyPublisher<Bool, Never> {
return flag.eraseToAnyPublisher()
}
func toggleFlag() {
flag.value.toggle()
}
}
class AnotherObservableObject: ObservableObject {
let ao = AnObservableObject()
#Published var flag = false
init() {
let flagPublisher = ao.flagPublisher()
flagPublisher
.receive(on: DispatchQueue.main)
.assign(to: &$flag)
}
func start() {
ao.start()
}
func stop() {
ao.stop()
}
}
struct MyView: View {
#StateObject var ao = AnotherObservableObject()
var body: some View {
VStack {
if ao.flag {
Image(systemName: "flag").foregroundColor(.green)
}
HStack {
Button(action: {ao.start()}, label: {
Text("Toggle Flag")
})
Button(action: {ao.stop()}, label: {
Text("Stop")
})
}
}
.padding()
}
}
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
// Make a UIHostingController
let viewController = UIHostingController(rootView: MyView())
// Assign it to the playground's liveView
PlaygroundPage.current.liveView = viewController
let myView = MyView()
Thoughts?
You don't need an ObservableObject for this; you can observe a Timer directly in your View.
struct MyView: View {
#State private var flag: Bool = false
#State var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
if flag {
Image(systemName: "flag").foregroundColor(.green)
}
HStack {
Button(action: {start()}, label: {
Text("Toggle Flag")
})
Button(action: {stop()}, label: {
Text("Stop")
})
}
.onReceive(timer) { _ in
flag.toggle()
}
}
.padding()
}
func start() {
timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
}
func stop() {
timer.upstream.connect().cancel()
}
}
Starting point is a NavigationView within a TabView. I'm struggling with finding a SwiftUI solution to pop to the root view within the navigation stack when the selected tab is tapped again. In the pre-SwiftUI times, this was as simple as the following:
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
let navController = viewController as! UINavigationController
navController.popViewController(animated: true)
}
Do you know how the same thing can be achieved in SwiftUI?
Currently, I use the following workaround that relies on UIKit:
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
let navigationController = UINavigationController(rootViewController: UIHostingController(rootView: MyCustomView() // -> this is a normal SwiftUI file
.environment(\.managedObjectContext, context)))
navigationController.tabBarItem = UITabBarItem(title: "My View 1", image: nil, selectedImage: nil)
// add more controllers that are part of tab bar controller
let tabBarController = UITabBarController()
tabBarController.viewControllers = [navigationController /* , additional controllers */ ]
window.rootViewController = tabBarController // UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
Here is possible approach. For TabView it gives the same behaviour as tapping to the another tab and back, so gives persistent look & feel.
Tested & works with Xcode 11.2 / iOS 13.2
Full module code:
import SwiftUI
struct TestPopToRootInTab: View {
#State private var selection = 0
#State private var resetNavigationID = UUID()
var body: some View {
let selectable = Binding( // << proxy binding to catch tab tap
get: { self.selection },
set: { self.selection = $0
// set new ID to recreate NavigationView, so put it
// in root state, same as is on change tab and back
self.resetNavigationID = UUID()
})
return TabView(selection: selectable) {
self.tab1()
.tabItem {
Image(systemName: "1.circle")
}.tag(0)
self.tab2()
.tabItem {
Image(systemName: "2.circle")
}.tag(1)
}
}
private func tab1() -> some View {
NavigationView {
NavigationLink(destination: TabChildView()) {
Text("Tab1 - Initial")
}
}.id(self.resetNavigationID) // << making id modifiable
}
private func tab2() -> some View {
Text("Tab2")
}
}
struct TabChildView: View {
var number = 1
var body: some View {
NavigationLink("Child \(number)",
destination: TabChildView(number: number + 1))
}
}
struct TestPopToRootInTab_Previews: PreviewProvider {
static var previews: some View {
TestPopToRootInTab()
}
}
Here's an approach that uses a PassthroughSubject to notify the child view whenever the tab is re-selected, and a view modifier to allow you to attach .onReselect() to a view.
import SwiftUI
import Combine
enum TabSelection: String {
case A, B, C // etc
}
private struct DidReselectTabKey: EnvironmentKey {
static let defaultValue: AnyPublisher<TabSelection, Never> = Just(.Mood).eraseToAnyPublisher()
}
private struct CurrentTabSelection: EnvironmentKey {
static let defaultValue: Binding<TabSelection> = .constant(.Mood)
}
private extension EnvironmentValues {
var tabSelection: Binding<TabSelection> {
get {
return self[CurrentTabSelection.self]
}
set {
self[CurrentTabSelection.self] = newValue
}
}
var didReselectTab: AnyPublisher<TabSelection, Never> {
get {
return self[DidReselectTabKey.self]
}
set {
self[DidReselectTabKey.self] = newValue
}
}
}
private struct ReselectTabViewModifier: ViewModifier {
#Environment(\.didReselectTab) private var didReselectTab
#State var isVisible = false
let action: (() -> Void)?
init(perform action: (() -> Void)? = nil) {
self.action = action
}
func body(content: Content) -> some View {
content
.onAppear {
self.isVisible = true
}.onDisappear {
self.isVisible = false
}.onReceive(didReselectTab) { _ in
if self.isVisible, let action = self.action {
action()
}
}
}
}
extension View {
public func onReselect(perform action: (() -> Void)? = nil) -> some View {
return self.modifier(ReselectTabViewModifier(perform: action))
}
}
struct NavigableTabViewItem<Content: View>: View {
#Environment(\.didReselectTab) var didReselectTab
let tabSelection: TabSelection
let imageName: String
let content: Content
init(tabSelection: TabSelection, imageName: String, #ViewBuilder content: () -> Content) {
self.tabSelection = tabSelection
self.imageName = imageName
self.content = content()
}
var body: some View {
let didReselectThisTab = didReselectTab.filter( { $0 == tabSelection }).eraseToAnyPublisher()
NavigationView {
self.content
.navigationBarTitle(tabSelection.localizedStringKey, displayMode: .inline)
}.tabItem {
Image(systemName: imageName)
Text(tabSelection.localizedStringKey)
}
.tag(tabSelection)
.navigationViewStyle(StackNavigationViewStyle())
.keyboardShortcut(tabSelection.keyboardShortcut)
.environment(\.didReselectTab, didReselectThisTab)
}
}
struct NavigableTabView<Content: View>: View {
#State private var didReselectTab = PassthroughSubject<TabSelection, Never>()
#State private var _selection: TabSelection = .Mood
let content: Content
init(#ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
let selection = Binding(get: { self._selection },
set: {
if self._selection == $0 {
didReselectTab.send($0)
}
self._selection = $0
})
TabView(selection: selection) {
self.content
.environment(\.tabSelection, selection)
.environment(\.didReselectTab, didReselectTab.eraseToAnyPublisher())
}
}
}
Here's how I did it:
struct UIKitTabView: View {
var viewControllers: [UIHostingController<AnyView>]
init(_ tabs: [Tab]) {
self.viewControllers = tabs.map {
let host = UIHostingController(rootView: $0.view)
host.tabBarItem = $0.barItem
return host
}
}
var body: some View {
TabBarController(controllers: viewControllers).edgesIgnoringSafeArea(.all)
}
struct Tab {
var view: AnyView
var barItem: UITabBarItem
init<V: View>(view: V, barItem: UITabBarItem) {
self.view = AnyView(view)
self.barItem = barItem
}
}
}
struct TabBarController: UIViewControllerRepresentable {
var controllers: [UIViewController]
func makeUIViewController(context: Context) -> UITabBarController {
let tabBarController = UITabBarController()
tabBarController.viewControllers = controllers
tabBarController.delegate = context.coordinator
return tabBarController
}
func updateUIViewController(_ uiViewController: UITabBarController, context: Context) { }
}
extension TabBarController {
func makeCoordinator() -> TabBarController.Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UITabBarControllerDelegate {
var parent: TabBarController
init(_ parent: TabBarController){self.parent = parent}
var previousController: UIViewController?
private var shouldSelectIndex = -1
func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
shouldSelectIndex = tabBarController.selectedIndex
return true
}
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
if shouldSelectIndex == tabBarController.selectedIndex {
if let navVC = tabBarController.viewControllers![shouldSelectIndex].nearestNavigationController {
if (!(navVC.popViewController(animated: true) != nil)) {
navVC.viewControllers.first!.scrollToTop()
}
}
}
}
}
}
extension UIViewController {
var nearestNavigationController: UINavigationController? {
if let selfTypeCast = self as? UINavigationController {
return selfTypeCast
}
if children.isEmpty {
return nil
}
for child in self.children {
return child.nearestNavigationController
}
return nil
}
}
extension UIViewController {
func scrollToTop() {
func scrollToTop(view: UIView?) {
guard let view = view else { return }
switch view {
case let scrollView as UIScrollView:
if scrollView.scrollsToTop == true {
scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.safeAreaInsets.top), animated: true)
return
}
default:
break
}
for subView in view.subviews {
scrollToTop(view: subView)
}
}
scrollToTop(view: view)
}
}
Then in ContentView.swift I use it like this:
struct ContentView: View {
var body: some View {
ZStack{
UIKitTabView([
UIKitTabView.Tab(
view: FirstView().edgesIgnoringSafeArea(.top),
barItem: UITabBarItem(title: "Tab1", image: UIImage(systemName: "star"), selectedImage: UIImage(systemName: "star.fill"))
),
UIKitTabView.Tab(
view: SecondView().edgesIgnoringSafeArea(.top),
barItem: UITabBarItem(title: "Tab2", image: UIImage(systemName: "star"), selectedImage: UIImage(systemName: "star.fill"))
),
])
}
}
}
Note that when the user is already on the root view, it scrolls to top automatically
Here's what I did with introspect swiftUI library.
https://github.com/siteline/SwiftUI-Introspect
struct TabBar: View {
#State var tabSelected: Int = 0
#State var navBarOne: UINavigationController?
#State var navBarTwo: UINavigationController?
#State var navBarThree: UINavigationController?
var body: some View {
return TabView(selection: $tabSelected){
NavView(navigationView: $navBarOne).tabItem {
Label("Home1",systemImage: "bag.fill")
}.tag(0)
NavView(navigationView: $navBarTwo).tabItem {
Label("Orders",systemImage: "scroll.fill" )
}.tag(1)
NavView(navigationView: $navBarThree).tabItem {
Label("Wallet", systemImage: "dollarsign.square.fill" )
// Image(systemName: tabSelected == 2 ? "dollarsign.square.fill" : "dollarsign.square")
}.tag(2)
}.onTapGesture(count: 2) {
switch tabSelected{
case 0:
self.navBarOne?.popToRootViewController(animated: true)
case 1:
self.navBarTwo?.popToRootViewController(animated: true)
case 2:
self.navBarThree?.popToRootViewController(animated: true)
default:
print("tapped")
}
}
}
}
NavView:
import SwiftUI
import Introspect
struct NavView: View {
#Binding var navigationView: UINavigationController?
var body: some View {
NavigationView{
VStack{
NavigationLink(destination: Text("Detail view")) {
Text("Go To detail")
}
}.introspectNavigationController { navController in
navigationView = navController
}
}
}
}
This actually isn't the best approach because it makes the entire tab view and everything inside of it have the double-tap gesture which would pop the view to its root. My current fix for this allows for one tap to pop up root view haven't figured out how to add double tap
struct TabBar: View {
#State var tabSelected: Int = 0
#State var navBarOne: UINavigationController?
#State var navBarTwo: UINavigationController?
#State var navBarThree: UINavigationController?
#State var selectedIndex:Int = 0
var selectionBinding: Binding<Int> { Binding(
get: {
self.selectedIndex
},
set: {
if $0 == self.selectedIndex {
popToRootView(tabSelected: $0)
}
self.selectedIndex = $0
}
)}
var body: some View {
return TabView(selection: $tabSelected){
NavView(navigationView: $navBarOne).tabItem {
Label("Home1",systemImage: "bag.fill")
}.tag(0)
NavView(navigationView: $navBarTwo).tabItem {
Label("Orders",systemImage: "scroll.fill" )
}.tag(1)
NavView(navigationView: $navBarThree).tabItem {
Label("Wallet", systemImage: "dollarsign.square.fill" )
// Image(systemName: tabSelected == 2 ? "dollarsign.square.fill" : "dollarsign.square")
}.tag(2)
}
}
func popToRootView(tabSelected: Int){
switch tabSelected{
case 0:
self.navBarOne?.popToRootViewController(animated: true)
case 1:
self.navBarTwo?.popToRootViewController(animated: true)
case 2:
self.navBarThree?.popToRootViewController(animated: true)
default:
print("tapped")
}
}
}
I took an approach similar to Asperi
Use a combination of a custom binding, and a separately stored app state var for keeping state of the navigation link.
The custom binding allows you to see all taps basically even when the current tab is the one thats tapped, something that onChange of tab selection binding doesn't show. This is what imitates the UIKit TabViewDelegate behavior.
This doesn't require a "double tap", if you just a single tap of the current, if you want double tap you'll need to implement your own tap/time tracking but shouldn't be too hard.
class AppState: ObservableObject {
#Published var mainViewShowingDetailView = false
}
struct ContentView: View {
#State var tabState: Int = 0
#StateObject var appState = AppState()
var body: some View {
let binding = Binding<Int>(get: { tabState },
set: { newValue in
if newValue == tabState { // tapped same tab they're already on
switch newValue {
case 0: appState.mainViewShowingDetailView = false
default: break
}
}
tabState = newValue // make sure you actually set the storage
})
TabView(selection: binding) {
MainView()
.tabItem({ Label("Home", systemImage: "list.dash") })
.tag(0)
.environmentObject(appState)
}
}
}
struct MainView: View {
#EnvironmentObject var appState: AppState
var body: {
NavigationView {
VStack {
Text("Hello World")
NavigationLink(destination: DetailView(),
isActive: $appState.mainViewShowingDetailView,
label: { Text("Show Detail") })
}
}
}
}
struct DetailView: View {
...
}
iOS 16 / NavigationStack approach with PassthroughSubject
Uses willSet on selectedTab to get the tap event, and uses a PassthroughSubject for sending the event to the children. This is picked up by the .onReceived and calls a function for popping the views from the NavigationStack
Did a full write up here: https://kentrobin.com/home/tap-tab-to-go-back/ and created a working demo project here: https://github.com/kentrh/demo-tap-tab-to-go-back
class HomeViewModel: ObservableObject {
#Published var selectedTab: Tab = .tab1 {
willSet {
if selectedTab == newValue {
subject.send(newValue)
}
}
}
let subject = PassthroughSubject<Tab, Never>()
enum Tab: Int {
case tab1 = 0
}
}
struct HomeView: View {
#StateObject var viewModel: HomeViewModel = .init()
var body: some View {
TabView(selection: $viewModel.selectedTab) {
Tab1View(subject: viewModel.subject)
.tag(HomeViewModel.Tab.tab1)
.tabItem {
Label("Tab 1", systemImage: "1.lane")
Text("Tab 1", comment: "Tab bar title")
}
}
}
}
struct Tab1View: View {
#StateObject var viewModel: Tab1ViewModel = .init()
let subject: PassthroughSubject<HomeViewModel.Tab, Never>
var body: some View {
NavigationStack(path: $viewModel.path) {
List {
NavigationLink(value: Tab1ViewModel.Route.viewOne("From tab 1")) {
Text("Go deeper to OneView")
}
NavigationLink(value: Tab1ViewModel.Route.viewTwo("From tab 1")) {
Text("Go deeper to TwoView")
}
}
.navigationTitle("Tab 1")
.navigationDestination(for: Tab1ViewModel.Route.self, destination: { route in
switch route {
case let .viewOne(text):
Text(text)
case let .viewTwo(text):
Text(text)
}
})
.onReceive(subject) { tab in
if case .tab1 = tab { viewModel.tabBarTapped() }
}
}
}
}
class Tab1ViewModel: ObservableObject {
#Published var path: [Route] = []
func tabBarTapped() {
if path.count > 0 {
path.removeAll()
}
}
enum Route: Hashable {
case viewOne(String)
case viewTwo(String)
}
}
Context
I have created a UIViewRepresentable to wrap a UITextField so that:
it can be set it to become the first responder when the view loads.
the next textfield can be set to become the first responder when enter is pressed
Problem
When used inside a NavigationView, unless the keyboard is dismissed from previous views, the view doesn't observe the value in their ObservedObject.
Question
Why is this happening? What can I do to fix this behaviour?
Screenshots
Keyboard from root view not dismissed:
Keyboard from root view dismissed:
Code
Here is the said UIViewRepresentable
struct SimplifiedFocusableTextField: UIViewRepresentable {
#Binding var text: String
private var isResponder: Binding<Bool>?
private var placeholder: String
private var tag: Int
public init(
_ placeholder: String = "",
text: Binding<String>,
isResponder: Binding<Bool>? = nil,
tag: Int = 0
) {
self._text = text
self.placeholder = placeholder
self.isResponder = isResponder
self.tag = tag
}
func makeUIView(context: UIViewRepresentableContext<SimplifiedFocusableTextField>) -> UITextField {
// create textfield
let textField = UITextField()
// set delegate
textField.delegate = context.coordinator
// configure textfield
textField.placeholder = placeholder
textField.setContentHuggingPriority(.defaultHigh, for: .vertical)
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textField.tag = self.tag
// return
return textField
}
func makeCoordinator() -> SimplifiedFocusableTextField.Coordinator {
return Coordinator(text: $text, isResponder: self.isResponder)
}
func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext<SimplifiedFocusableTextField>) {
// update text
uiView.text = text
// set first responder ONCE
if self.isResponder?.wrappedValue == true && !uiView.isFirstResponder && !context.coordinator.didBecomeFirstResponder{
uiView.becomeFirstResponder()
context.coordinator.didBecomeFirstResponder = true
}
}
class Coordinator: NSObject, UITextFieldDelegate {
#Binding var text: String
private var isResponder: Binding<Bool>?
var didBecomeFirstResponder = false
init(text: Binding<String>, isResponder: Binding<Bool>?) {
_text = text
self.isResponder = isResponder
}
func textFieldDidChangeSelection(_ textField: UITextField) {
text = textField.text ?? ""
}
func textFieldDidBeginEditing(_ textField: UITextField) {
DispatchQueue.main.async {
self.isResponder?.wrappedValue = true
}
}
func textFieldDidEndEditing(_ textField: UITextField) {
DispatchQueue.main.async {
self.isResponder?.wrappedValue = false
}
}
}
}
And to reproduce, here is the contentView:
struct ContentView: View {
var body: some View {
return NavigationView { FieldView(tag: 0) }
}
}
and here's the view with the field and its view model
struct FieldView: View {
#ObservedObject private var viewModel = FieldViewModel()
#State private var focus = false
var tag: Int
var body: some View {
return VStack {
// listen to viewModel's value
Text(viewModel.value)
// text field
SimplifiedFocusableTextField("placeholder", text: self.$viewModel.value, isResponder: $focus, tag: self.tag)
// push to stack
NavigationLink(destination: FieldView(tag: self.tag + 1)) {
Text("Continue")
}
// dummy for tapping to dismiss keyboard
Color.green
}
.onAppear {
self.focus = true
}.dismissKeyboardOnTap()
}
}
public extension View {
func dismissKeyboardOnTap() -> some View {
modifier(DismissKeyboardOnTap())
}
}
public struct DismissKeyboardOnTap: ViewModifier {
public func body(content: Content) -> some View {
return content.gesture(tapGesture)
}
private var tapGesture: some Gesture {
TapGesture().onEnded(endEditing)
}
private func endEditing() {
UIApplication.shared.connectedScenes
.filter {$0.activationState == .foregroundActive}
.map {$0 as? UIWindowScene}
.compactMap({$0})
.first?.windows
.filter {$0.isKeyWindow}
.first?.endEditing(true)
}
}
class FieldViewModel: ObservableObject {
var subscriptions = Set<AnyCancellable>()
// diplays
#Published var value = ""
}
It looks like SwiftUI rendering engine again over-optimized...
Here is fixed part - just make destination unique forcefully using .id. Tested with Xcode 11.4 / iOS 13.4
NavigationLink(destination: FieldView(tag: self.tag + 1).id(UUID())) {
Text("Continue")
}