SwiftUI TabView with shared content view across multiple tabs - swift

I am building an application that is based around WKWebView but uses some native elements. I am looking to add tabs to it and I reached for TabView, but the problem I am facing is that for each tab, I need to have a new instance of WKWebView:
TabView(selection: $tab) {
ForEach(tabs, id: \.0) { (tag, name, icon) in
MyWebView()
.tabItem {
Image(systemName: icon)
Text(name)
}
.tag(tag)
}
}
.onChange(of: tab) { tab in
// postMessage to the WKWebView
}
This makes it so that each tab has its own WKWebView instance and whenever I tap on a tab, it sends a message to the associated web view.
I would however prefer to have a single WKWebView and somehow wire up the TabView such that it would show that same single WKWebView for each tab so that I could just send a message to the sole WKWebView instructing it to switch to the web view appropriate for the current tab.
Is this possible? Can I perhaps create a row of tabs like this without using TabView so that I could stack the sole WKWebView on top of this row?

It is possible to have single static instance of WKWebView in representable, everything else depends on app logic
struct MyWebView: UIViewRepresentable {
static let nativeWebView = WKWebView() // << here !!
func makeUIView(context: Context) -> some UIView {
Self.nativeWebView // << here !!
}
func updateUIView(_ uiView: UIViewType, context: Context) {
}
}

Related

Is it possible to override SwiftUI modifiers?

Knowing that with SwiftUI view modifiers, order matters - because each modifier is a part of a chain of modifiers, I was wondering if it was possible to reset/overwrite/override a modifier (or the whole chain?
Specifically, I'm wondering about Styles (groupBoxStyle, buttonStyle, etc). I have default styles that I want to use in 90% of my app, and a few pages will have slightly different styles for those widgets.
For example:
// Renders a button with the "light" style
Button("Hello world") {
}
.buttonStyle(LightButtonStyle())
.buttonStyle(DarkButtonStyle())
// Renders a button with the "dark" style
Button("Hello world") {
}
.buttonStyle(DarkButtonStyle())
.buttonStyle(LightButtonStyle())
In those cases, I would actually like the 2nd modifier to be used, but the 1st takes over and subsequent styles don't work.
Note: In my actual app, none of my use cases are this trivial - this is just the simplest proof of concept.
The workaround(s) I have are that I create separate LightButton and DarkButton views, but that feels very inelegant (and becomes a mess when I have 5-6 variants of each component).
Alternatively, I have a custom MyButton(myStyle: ButtonStyle = .myDefaultStyle), but since this is a forms app, there are about 50-60 locations where something like that needs to be updated (instead of applying a modifier at a top level and letting that cascade through).
Edit: I should note, where I can set a top-level style and let it cascade, that works very well and as expected (closer to the View, the modifier takes over). But, there are just some weird use cases where it would be nice to flip the script.
Generally, buttonStyle propagates to child views, so ideally you would only need to set your “house style” once on the root view of your app.
The well-known place where this fails to work is the presentation modifiers like .sheet, which do not propagate styles to the presented view hierarchy. So you will need to write your own versions of the presentation modifiers that re-apply your house style.
For example, here's a custom ButtonStyle:
struct HouseButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(20)
.background {
Capsule(style: .continuous)
.foregroundColor(.pink)
}
.saturation(configuration.isPressed ? 1 : 0.5)
}
}
And here's a cover for sheet that applies the custom button style to the presented content:
extension View {
func houseSheet<Content: View>(
isPresented: Binding<Bool>,
onDismiss: (() -> Void)? = nil,
#ViewBuilder content: #escaping () -> Content
) -> some View {
return sheet(isPresented: isPresented, onDismiss: onDismiss) {
content()
.buttonStyle(HouseButtonStyle())
}
}
}
We can test out whether a NavigationLink, a sheet, and a houseSheet propagate the button style:
struct ContentView: View {
#State var showingHouseSheet = false
#State var showingStandardSheet = false
var body: some View {
NavigationView {
VStack {
NavigationLink("Navigation Push") {
ContentView()
}
Button("Standard Sheet") {
showingStandardSheet = true
}
Button("House Sheet") {
showingHouseSheet = true
}
}
.sheet(isPresented: $showingStandardSheet) {
ContentView()
}
.houseSheet(isPresented: $showingHouseSheet) {
ContentView()
}
}
}
}
Here's the root view that applies the house button style at the highest level:
struct RootView: View {
var body: some View {
ContentView()
.buttonStyle(HouseButtonStyle())
}
}
If you play with this, you'll find that both NavigationLink and houseSheet propagate the button style to the presented content, but sheet does not.

SwiftUI ScrollView not properly setting content offset when nested in UIViewRepresentable

I'm trying to introspect the UIScrollView under the hood of a SwiftUI ScrollView and that's all fine and dandy.
But I noticed that there were some issues with spacing and animation when scrolling going the pure representable approach so instead I'm trying to leverage a few properties of the underlying UIScrollView via a few bindings that are updates as part of the scrollview delegate.
That's not the main concern, the main issue I'm having even if I'm not doing any bindings at all but a bare bones approach of using the ScrollView in a UIViewRepresentable context is that it behaves differently.
public struct PerfectScrollView<Content: View>: UIViewRepresentable {
private let content: Content
public init(#ViewBuilder content: () -> Content) {
self.content = content()
}
public func updateUIView(_ uiView: UIViewType, context: Context) {
}
public func makeUIView(context: Context) -> UIView {
UIHostingController(rootView:
ScrollView {
content
}
).view
}
}
// Usage
PerfectScrollView {
ForEach(0..<100) { Text("Hello \($0)") }
}
// vs
ScrollView {
ForEach(0..<100) { Text("Hello \($0)") }
}
The result of PerfectScrollView renders this where I'm centered in the middle of the scrollview's content.
But the normal ScrollView (not setup via UIViewRepresentable protocol) renders an appropriate scrollview.
Any ideas about what actually is happening? From interacting with he PerfectScrollView it's like I've reached the end/bounds of the view and trying to scroll results in the rubber band style resistance animation scrolling up or down.
Any help/feedback would be greatly appreciated :)
The solution was to use UIViewControllerRepresentable protocol since i was using ScrollView in a UIHostingController context.

Run action when view is 'removed'

I am developing an app which uses UIKit. I have integrated a UIKit UIViewController inside SwiftUI and everything works as expected. I am still wondering if there is a way to 'know' when a SwiftUI View is completely gone.
My understanding is that a #StateObject knows this information. I now have some code in the deinit block of the corresponding class of the StateObject. There is some code running which unsubscribes the user of that screen.
The problem is that it is a fragile solution. In some scenario's the deinit block isn't called.
Is there any recommended way to know if the user pressed the back button in a SwiftUI View (or swiped the view away)? I don't want to get notified with the .onDisppear modifier because that is also called when the user taps somewhere on the screen which adds another view to the navigation stack. I want to run some code once when the screen is completely gone.
Is there any recommended way to know if the user pressed the back button in a SwiftUI View (or swiped the view away)?
This implies you're using a NavigationView and presenting your view with a NavigationLink.
You can be notified when the user goes “back” from your view by using one of the NavigationLink initializers that takes a Binding. Create a custom binding and in its set function, check whether the old value is true (meaning the child view was presented) and the new value is false (meaning the child view is now being popped from the stack). Example:
struct ContentView: View {
#State var childIsPresented = false
#State var childPopCount = 0
var body: some View {
NavigationView {
VStack {
Text("Child has been popped \(childPopCount) times")
NavigationLink(
"Push Child",
isActive: Binding(
get: { childIsPresented },
set: {
if childIsPresented && !$0 {
childPopCount += 1
}
childIsPresented = $0
}
)
) {
ChildView()
}
}
}
}
}
struct ChildView: View {
var body: some View {
VStack {
Text("Sweet child o' mine")
NavigationLink("Push Grandchild") {
GrandchildView()
}
}
}
}
struct GrandchildView: View {
var body: some View {
VStack {
Text("👶")
.font(.system(size: 100))
}
}
}
Note that these initializers, and NavigationView, are deprecated if your deployment target is iOS 16. In that case, you'll want to use a NavigationStack and give it a custom Binding that performs the pop-detection.

How does the modifier .navigationTitle in SwiftUI work?

I've known the .navigationTitle is the extension function of View, but how to explain the following examples?
var body: some View {
NavigationView{
ScrollView{
ForEach(1..<100){ item in
Text("Hello, \(item)!")
.navigationTitle("Test\(item)")
}
}
}
.navigationTitle("title in navigation")
}
The result show that only the modifier of first widget inside NavigationView effected.
code results
I think the best choice is: modifier .navigationTitle is effective in NavigationView instead of the first widget inside NavigationView.
iOS will show the first innermost .navigationTitle.
Your outer title will never show, as it cannot be attached to "NavigationViewitself.
From the docs:
A view’s navigation title is used to visually display the current navigation state of an interface. On iOS and watchOS, when a view is navigated to inside of a navigation view, that view’s title is displayed in the navigation bar. On iPadOS, the primary destination’s navigation title is reflected as the window’s title in the App Switcher. Similarly on macOS, the primary destination’s title is used as the window title in the titlebar, Windows menu and Mission Control.
It doesn't really matter if .navigationTitle is inside a NavigationView or not. What .navigationTitle does is finds the UIView that the View is being displayed in, then searches for the UIViewController containing that UIView and it sets its navigationItem.title. That fact that only the first title param is used is probably some implementation detail, e.g. if this value is already set don't set it again, because obviously searching the UIView and UIViewController hierarchy is an expensive operation they would want to avoid.
You can verify this by implementing a UINavigationController in SwiftUI using UIViewControllerRepresentable. Then when you put a UIHostingController in the stack, if the SwiftUI View uses .navigationTitle then it still works. e.g.
struct NavigationControllerTestView: View {
var body: some View {
MyNavigation {
Text("Test Text")
.navigationTitle("Test Title") // works despite no NavigationView
.toolbar {
EditButton()
}
}
}
}
struct MyNavigation<Content: View>: UIViewControllerRepresentable {
let content: Content
init(#ViewBuilder content: () -> Content) {
self.content = content()
}
func makeUIViewController(context: Context) -> UINavigationController {
let hc = UIHostingController(rootView: content)
hc.rootView = content
let vc = UINavigationController(rootViewController: hc)
return vc
}
func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {
}
typealias UIViewControllerType = UINavigationController
}

Is page-based navigation possible in a SwiftUI app on WatchOS?

I see page-based navigation is supported for WatchOS, but is there a way to access this functionality with SwiftUI?
If you are using the new SwiftUI App Lifecycle and can't access Storyboards (or just need a solution for App Delegate Cycle without using Storyboards), you can use the new Page View style in SwiftUI 2:
WindowGroup {
TabView {
Page1View()
Page2View()
}
.tabViewStyle(PageTabViewStyle())
}
Yes, this is possible. First, define hosting controllers for each of your pages like so
class Page1HostingController: WKHostingController<Page1View> {
override var body: Page1View {
self.setTitle("page1")
return Page1View()
}
}
class Page2HostingController: WKHostingController<Page2View> {
override var body: Page2View {
self.setTitle("page2")
return Page2View()
}
}
where Page1View and Page2View are your SwiftUI implementations.
Then, add new view controllers to your Interface.storyboard, set their implementation classes to your new controller classes and connect them via a "next page" segue.
There is NavigationLink button which triggers a navigation presentation when pressed.
var body: some View {
VStack() {
NavigationLink(destination: Text("Destination view"), label: {
Text("Title of the NavigationLink button")
})
}
}