I have a state the decides if we need to do round up or down or nothing:
enum RoundingType: Codable {
case up
case down
}
struct ViewState {
var roundingType: RoundingType? = nil
}
Then in the toggle I simply update this flag:
#MainActor
class ViewModel: ObservableObject {
#Published var state: ViewState
func toggleRoundingType(_ roundingType: RoundingType) {
let oldRoundingType = state.roundingType
// if same rounding type, cancel it
// Otherwise, set it
let isCancel = oldRoundingType == roundingType
if isCancel {
state.roundingType = nil
} else {
state.roundingType = roundingType
}
}
}
This is my View:
struct HomeView: View {
#StateObject var viewModel: ViewModel
var body: some View {
let state = viewModel.state
HStack {
Spacer()
SUITextToggle(label: loc(.roundUp), isOn: state.roundingType == .up) { _ in
viewModel.toggleRoundingType(.up)
}
Spacer()
SUITextToggle(label: loc(.roundDown), isOn: state.roundingType == .down) { _ in
viewModel.toggleRoundingType(.down)
}
Spacer()
}
}
}
This view renders 2 toggles, and user can turn on/off the round up/down toggles.
This is my toggle implementation:
public struct SUITextToggle: View {
#State var isOn: Bool
private var binding: Binding<Bool> {
Binding<Bool> {
return isOn
} set: { newValue in
isOn = newValue
onChange(newValue)
}
}
let label: String
let onChange: (Bool) -> Void
init(label: String, isOn: Bool, onChange: #escaping (Bool) -> Void) {
self.label = label
self.isOn = isOn
self.onChange = onChange
}
public var body: some View {
Toggle(label, isOn: binding)
.toggleStyle(.button)
}
}
Now I have an issue that when I turn on "Up", then turn on "Down", the "Up" button is not automatically turned off. For some reason the "Up" button is not refreshed.
EDIT:
minimal reproducible example:
import SwiftUI
import UIKit
public struct SUITextToggle: View {
#State var isOn: Bool
private var binding: Binding<Bool> {
Binding<Bool> {
return isOn
} set: { newValue in
isOn = newValue
onChange(newValue)
}
}
let label: String
let onChange: (Bool) -> Void
init(label: String, isOn: Bool, onChange: #escaping (Bool) -> Void) {
self.label = label
self.isOn = isOn
self.onChange = onChange
}
public var body: some View {
Toggle(label, isOn: binding)
.toggleStyle(.button)
}
}
struct HomeView: View {
#State var isOn: Bool = true
var body: some View {
// Only one of these 2 toggles should be on
SUITextToggle(label: "Toggle 1", isOn: isOn) { _ in
isOn = !isOn
}
SUITextToggle(label: "Toggle 2", isOn: !isOn) { _ in
isOn = !isOn
}
}
}
class ViewController: UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let hostingVC = UIHostingController(rootView: HomeView())
present(hostingVC, animated: true, completion: nil)
}
}
The issue is that there is no two-way communication that is compatible with SwiftUI. You communicate one-way on init and then use the completion handler. That does not tell the other toggle to re-render.
I made some changes to make it more SwiftUI.
import SwiftUI
enum RoundingType: String, Codable, CaseIterable, CustomStringConvertible, Identifiable {
case up
case down
//Set the description here
var description: String{
"round\(rawValue)"
}
//Make the enum Identifiable
var id: String{
rawValue
}
}
//No changes
struct ViewState {
var roundingType: RoundingType? = nil
}
#MainActor
class HomeViewModel: ObservableObject {
//Set a default value. Missing from code
#Published var state: ViewState = .init(roundingType: .up)
//Remove func
}
#available(iOS 15.0, *)
struct ToggleHomeView: View {
#StateObject var viewModel: HomeViewModel = .init()
var body: some View {
HStack {
Spacer()
//Iterate through all the options and provide a toggle for each option
ForEach(RoundingType.allCases){ type in
SUITextToggle(selectedType: $viewModel.state.roundingType, toggleType: type, label: type.description)
Spacer()
}
}
}
}
#available(iOS 15.0, *)
struct ToggleHomeView_Previews: PreviewProvider {
static var previews: some View {
ToggleHomeView()
}
}
#available(iOS 15.0, *)
public struct SUITextToggle: View {
//Bindng is a two-way connection
#Binding var selectedType: RoundingType?
///type that toggle represents
let toggleType: RoundingType
///proxy that uses the selectedType and toggleType to set toggle to on/off if the two variables are the same
private var binding: Binding<Bool> {
Binding<Bool> {
return selectedType == toggleType
} set: { newValue in
if newValue{
selectedType = toggleType
}else{
//if you remove the nil set a default value here
selectedType = nil
}
}
}
let label: String
public var body: some View {
Toggle(label, isOn: binding)
.toggleStyle(.button)
}
}
But you can preserve most of your code if you remove the #State. This wrapper is meant to preserve its value through body's re-rendering.
#available(iOS 15.0, *)
public struct SUITextToggle: View {
var isOn: Bool
private var binding: Binding<Bool> {
Binding<Bool> {
return isOn
} set: { newValue in
onChange(newValue)
}
}
let label: String
let onChange: (Bool) -> Void
init(label: String, isOn: Bool, onChange: #escaping (Bool) -> Void) {
self.label = label
self.isOn = isOn
self.onChange = onChange
}
public var body: some View {
Toggle(label, isOn: binding)
.toggleStyle(.button)
}
}
Another "hack" that is out there is to force Views to recreate by setting the id but this causes unnecessary rendering. Efficiency issues as your app grows will become noticeable
#available(iOS 15.0, *)
struct ToggleHomeView: View {
#StateObject var viewModel: HomeViewModel = .init()
var body: some View {
let state = viewModel.state
HStack {
Spacer()
SUITextToggle(label: "roundUp", isOn: state.roundingType == .up) { _ in
viewModel.toggleRoundingType(.up)
}
Spacer()
SUITextToggle(label: "roundDown", isOn: state.roundingType == .down) { _ in
viewModel.toggleRoundingType(.down)
}
Spacer()
}.id(state.roundingType)
}
}
Related
So I'm trying to click on a button and change the mapType using the MKMapView API, but I can't seem to achieve it.
So here is what I have, we have the MKMapView file:
import SwiftUI
import MapKit
struct MapViewUIKit: UIViewRepresentable {
// Environment Objects
#EnvironmentObject var mainViewModel: MainViewModel
#EnvironmentObject private var mapSettings: MapSettings
// Coordinator function
final class Coordinator: NSObject, MKMapViewDelegate {
// Define this class.
var parent: MapViewUIKit
// Initialize this class.
init(_ parent: MapViewUIKit) {
self.parent = parent
}
// MARK: Display Annotation
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
// Unrelated code here.
}
// MARK: Select Annotation
func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
// Unrelated code here.
}
}
//
func makeCoordinator() -> Coordinator {
MapViewUIKit.Coordinator(self)
}
// MARK: CREATE MAP
func makeUIView(context: Context) -> MKMapView {
// Create map.
let mapView = MKMapView(frame: .zero)
// Coordinate our delegate.
mapView.delegate = context.coordinator
// Set our region for the map.
mapView.setRegion(DEFAULT_MK_REGION, animated: false)
// Set our map type to standard.
mapView.mapType = .standard
// Show user location
mapView.showsUserLocation = true
return mapView
}
// MARK: UPDATE MAP
func updateUIView(_ uiView: MKMapView, context: Context) {
updateMapType(uiView)
}
// Update our map type on selection.
private func updateMapType(_ uiView: MKMapView) {
switch mapSettings.mapType {
case 0:
if #available(iOS 16.0, *) {
let config = MKStandardMapConfiguration(elevationStyle: elevationStyle(), emphasisStyle: emphasisStyle())
config.pointOfInterestFilter = MKPointOfInterestFilter.excludingAll
config.showsTraffic = false
} else {
// Fallback on earlier versions
}
case 1:
if #available(iOS 16.0, *) {
uiView.preferredConfiguration = MKHybridMapConfiguration(elevationStyle: elevationStyle())
} else {
// Fallback
}
case 2:
if #available(iOS 16.0, *) {
uiView.preferredConfiguration = MKImageryMapConfiguration(elevationStyle: elevationStyle())
} else {
// Fallback on earlier versions
}
default:
break
}
}
// Set the elevation style.
#available(iOS 16.0, *)
private func elevationStyle() -> MKMapConfiguration.ElevationStyle {
if mapSettings.showElevation == 0 {
return MKMapConfiguration.ElevationStyle.realistic
} else {
return MKMapConfiguration.ElevationStyle.flat
}
}
// Set the emphasis style.
#available(iOS 16.0, *)
private func emphasisStyle() -> MKStandardMapConfiguration.EmphasisStyle {
if mapSettings.showEmphasisStyle == 0 {
return MKStandardMapConfiguration.EmphasisStyle.default
} else {
return MKStandardMapConfiguration.EmphasisStyle.muted
}
}
}
Then I have my MapDisplaySheetView, which contains the following buttons:
Here is the code that I am using:
struct MapDisplaySheetView: View {
#ObservedObject var mapSettings = MapSettings()
#Environment(\.dismiss) var dismiss
#State var mapType = 0
#State var showElevation = 0
#State var showEmphasis = 0
#State var mapDisplay: [String] = [
"Standard",
"Hybrid",
"Image",
]
#State var mapElevation: [String] = [
"Realistic",
"Flat",
]
#State var mapEmphasis: [String] = [
"Default",
"Muted",
]
var body: some View {
VStack(spacing: 0) {
// MARK: MapType
HStack {
ForEach(mapDisplay, id: \.self) { item in
VStack {
HStack {
VStack {
Button(action: {
switch item {
case "Standard": mapSettings.mapType = 0
case "Hybrid": mapSettings.mapType = 1
case "Image": mapSettings.mapType = 2
default: mapSettings.mapType = 0
}
print("User has selected \(item) map type.")
}, label: {
ZStack {
Text(item)
.multilineTextAlignment(.center)
}
}) //: Button
} //: VStack
} //: HStack
}
.onChange(of: mapType) { newValue in
mapSettings.mapType = newValue
log.info("The new map type is: \(newValue)")
}
} //: ForEach
} //: HStack
// MARK: Map Elevation
HStack {
ForEach(mapElevation, id: \.self) { item in
VStack {
HStack {
VStack {
Button(action: {
switch item {
case "Realistic": mapSettings.showElevation = 0
case "Flat": mapSettings.showElevation = 1
default: mapSettings.showElevation = 0
}
print("User has selected \(item) map elevation.")
}, label: {
ZStack {
Text(item)
.multilineTextAlignment(.center)
}
}) //: Button
}
}
}
}
ForEach(mapEmphasis, id: \.self) { item in
VStack {
HStack {
VStack {
Button(action: {
switch item {
case "Default": mapSettings.showEmphasisStyle = 0
case "Muted": mapSettings.showEmphasisStyle = 1
default: mapSettings.showEmphasisStyle = 0
}
print("User has selected \(item) map emphasis.")
}, label: {
ZStack {
Text(item)
.multilineTextAlignment(.center)
}
}) //: Button
}
}
}
}
} //: HStack
}
}
}
// Mapping
final class MapSettings: ObservableObject {
#Published var mapType = 0
#Published var showElevation = 0
#Published var showEmphasisStyle = 0
}
I am attempting to use case 0 for the top 3 buttons, which are the mapType and then case 1 for the bottom 2 left buttons and then case 2 for the bottom 2 right buttons, but I can't seem to get the map to update at all, which I believe there is an issue inside MapViewUIKit and got the mapType is set.
Could the issue be with mapView.mapType = .standard?
I am using this guide as an example: https://holyswift.app/new-mapkit-configurations-with-swiftui/
First, change your settings to a struct (you don't need a reference type in this situation):
struct MapSettings {
var mapType = 0
var showElevation = 0
var showEmphasisStyle = 0
}
Then fix the #ObservedObject to:
#State var mapSettings = MapSettings()
Then you can do
struct MapViewUIKit: UIViewRepresentable {
let mapSettings: MapSettings
updateUIView will be called when mapSettings has changed from the last time this View was init, and then you can use the new values to make any changes to MKMapView if required.
Another mistake is Coordinator(self), self is just a value which is immediately discarded after SwiftUI updates so that won't work, try this structure instead:
struct MyMapView: UIViewRepresentable {
#Binding var userTrackingMode: MapUserTrackingMode
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> MKMapView {
context.coordinator.mapView
}
func updateUIView(_ uiView: MKMapView, context: Context) {
// MKMapView has a strange design that the delegate is called when setting manually so we need to prevent an infinite loop
context.coordinator.userTrackingModeChanged = nil
uiView.userTrackingMode = userTrackingMode == .follow ? MKUserTrackingMode.follow : MKUserTrackingMode.none
context.coordinator.userTrackingModeChanged = { mode in
userTrackingMode = mode == .follow ? MapUserTrackingMode.follow : MapUserTrackingMode.none
}
}
class Coordinator: NSObject, MKMapViewDelegate {
lazy var mapView: MKMapView = {
let mv = MKMapView()
mv.delegate = self
return mv
}()
var userTrackingModeChanged: ((MKUserTrackingMode) -> Void)?
func mapView(_ mapView: MKMapView, didChange mode: MKUserTrackingMode, animated: Bool) {
userTrackingModeChanged?(mode)
}
}
}
I noticed that there is a difference in the behavior of the UIActivityViewController when :
1: press the CLOSE (X) button up there, or
2: do a SLIDE DOWN to do the dismiss.
Pressing the button returns a nil value in activityType, doing a slidedown it returns nothing.
So I can't dismiss the ProgressView when I do a slide down.
Do you have any tips ?
import SwiftUI
struct ContentView: View {
#StateObject var vm = MainViewModel()
var body: some View {
ZStack {
Button {
withAnimation(.spring()) {
vm.showShareProgress.toggle()
vm.showShareAV.toggle()
}
} label: {
Text("share")
}
if vm.showShareProgress {
ProgressView()
.padding()
.background(.thinMaterial)
.cornerRadius(15)
.transition(.scale)
}
}
.sheet(isPresented: $vm.showShareAV, content: { ActivityViewController(itemsToShare: [vm.contentToShare as Any]) })
.environmentObject(vm)
}
}
final class MainViewModel: ObservableObject {
#Published var showShareAV: Bool = false
#Published var showShareProgress = false
#Published var contentToShare: Any?
}
struct ActivityViewController: UIViewControllerRepresentable {
#EnvironmentObject var vm: MainViewModel
var itemsToShare: [Any]
var servicesToShareItem: [UIActivity]? = nil
func makeUIViewController(context: UIViewControllerRepresentableContext<ActivityViewController>) -> UIActivityViewController {
let controller = UIActivityViewController(activityItems: itemsToShare, applicationActivities: servicesToShareItem)
controller.completionWithItemsHandler = { (activityType: UIActivity.ActivityType?, completed: Bool, arrayReturnedItems: [Any]?, error: Error?) in
print("Sharing activity : \(String(describing: activityType?.rawValue))")
if completed { print("sharing OK") }
else { print("sharing canceled") }
if let sharingError = error { print("Sharing error : \(sharingError.localizedDescription)") }
vm.showShareProgress = false
}
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: UIViewControllerRepresentableContext<ActivityViewController>) {}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(MainViewModel())
}
}
I'm trying to wrap PKCanvasView as a SwiftUI view called CanvasView. I'd like to be able to toggle the whole canvas on top of another view. When the CanvasView appears, I'd like the PKToolPicker to appear. When it disappears, I'd like the PKToolPicker to disappear.
I've found a few similar approaches on here but they only involve showing the picker or toggling the picker with a button; I'd like the picker visibility to be tied to the view visibility.
In the below example you can see that you can toggle the canvas, but once the tool picker is visible, it stays visible.
Here's my CanvasView:
import SwiftUI
import PencilKit
struct CanvasView: UIViewRepresentable {
class Coordinator: NSObject, PKCanvasViewDelegate {
var canvasView: Binding<PKCanvasView>
let onChange: () -> Void
init(canvasView: Binding<PKCanvasView>, onChange: #escaping () -> Void) {
self.canvasView = canvasView
self.onChange = onChange
}
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
if canvasView.drawing.bounds.isEmpty == false {
onChange()
}
}
}
#Binding var canvasView: PKCanvasView
#Binding var toolPickerIsActive: Bool
private let toolPicker = PKToolPicker()
let onChange: () -> Void
func makeUIView(context: Context) -> PKCanvasView {
canvasView.backgroundColor = .clear
canvasView.isOpaque = true
canvasView.delegate = context.coordinator
showToolPicker()
return canvasView
}
func updateUIView(_ uiView: PKCanvasView, context: Context) {
toolPicker.setVisible(toolPickerIsActive, forFirstResponder: uiView)
}
func showToolPicker() {
toolPicker.setVisible(true, forFirstResponder: canvasView)
toolPicker.addObserver(canvasView)
canvasView.becomeFirstResponder()
}
func makeCoordinator() -> Coordinator {
Coordinator(canvasView: $canvasView, onChange: onChange)
}
}
And an example ContentView:
struct ContentView: View {
#State private var canvasView = PKCanvasView()
#State private var toolPickerIsActive = false
#State private var canvasIsVisible = false
var body: some View {
ZStack {
if canvasIsVisible {
CanvasView(canvasView: $canvasView,
toolPickerIsActive: $toolPickerIsActive,
onChange: canvasDidChange)
.onAppear { toolPickerIsActive = true }
.onDisappear { toolPickerIsActive = false }
}
Button(action: {
canvasIsVisible.toggle()
}, label: {
Text("Toggle canvas view")
})
}
}
private func canvasDidChange() {
// Do something with updated canvas.
}
}
Any guidance would be much appreciated!
In your scenario the CanvasView is destroyed on disappear, so SwiftUI rendering engine just not update it on any state change (as it see that no needs for that).
The possible solution for this use-case is to hide picker on coordinator deinit (because it is destroyed with owner view).
Here is a demo. Tested with Xcode 12.4 / iOS 14.4
struct CanvasView: UIViewRepresentable {
class Coordinator: NSObject, PKCanvasViewDelegate {
var canvasView: Binding<PKCanvasView>
let onChange: () -> Void
private let toolPicker: PKToolPicker
deinit { // << here !!
toolPicker.setVisible(false, forFirstResponder: canvasView.wrappedValue)
toolPicker.removeObserver(canvasView.wrappedValue)
}
init(canvasView: Binding<PKCanvasView>, toolPicker: PKToolPicker, onChange: #escaping () -> Void) {
self.canvasView = canvasView
self.onChange = onChange
self.toolPicker = toolPicker
}
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
if canvasView.drawing.bounds.isEmpty == false {
onChange()
}
}
}
#Binding var canvasView: PKCanvasView
#Binding var toolPickerIsActive: Bool
private let toolPicker = PKToolPicker()
let onChange: () -> Void
func makeUIView(context: Context) -> PKCanvasView {
canvasView.backgroundColor = .clear
canvasView.isOpaque = true
canvasView.delegate = context.coordinator
showToolPicker()
return canvasView
}
func updateUIView(_ uiView: PKCanvasView, context: Context) {
toolPicker.setVisible(toolPickerIsActive, forFirstResponder: uiView)
}
func showToolPicker() {
toolPicker.setVisible(true, forFirstResponder: canvasView)
toolPicker.addObserver(canvasView)
canvasView.becomeFirstResponder()
}
func makeCoordinator() -> Coordinator {
Coordinator(canvasView: $canvasView, toolPicker: toolPicker, onChange: onChange)
}
}
struct ContentView: View {
#State private var canvasView = PKCanvasView()
#State private var toolPickerIsActive = false
#State private var canvasIsVisible = false
var body: some View {
ZStack {
if canvasIsVisible {
CanvasView(canvasView: $canvasView,
toolPickerIsActive: $toolPickerIsActive,
onChange: canvasDidChange)
.onAppear { toolPickerIsActive = true }
// .onDisappear { toolPickerIsActive = false }
}
Button(action: {
canvasIsVisible.toggle()
}, label: {
Text("Toggle canvas view")
})
}
}
private func canvasDidChange() {
// Do something with updated canvas.
}
}
Note: there might be redesign of ownership, so toolPicker will live only within coordinator, but it does not change idea, and is up to you.
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")
}