Is it possible to override SwiftUI modifiers? - swift

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.

Related

ZStack explicit animation not working when conditionally hiding/showing a view

I want to create some sort of banner view that either moves in or out with animations when some state changes in SwiftUI. It feels like a basic task to do but the transition when showing the banner is not being animated. Hiding works fine.
So for demonstration purposes, here is a basic view modifier that creates a ZStack with the content and a conditional view that acts as the banner overlay and has a transition:
struct BannerViewModifier: ViewModifier {
let showBanner: Bool
func body(content: Content) -> some View {
ZStack {
content
if showBanner {
VStack {
Text("banner")
Spacer()
}.transition(.move(edge: .top).combined(with: .opacity))
}
}
}
}
If I would then go ahead and tack that view modifier onto a button that toggles a boolean like this (with explicit animations)...
#State var showBanner = false
var body: some View {
Button(showBanner ? "Hide banner" : "Show banner") {
withAnimation { showBanner.toggle() }
}.modifier(BannerViewModifier(showBanner: showBanner))
}
... it results in not animating the show transition as I already mentioned.
Solutions I've tried already:
When adding an implicit animation to the VStack with the transition modifier like this:
.animation(.default).transition(.move(edge: .top).combined(with: .opacity)) the show animation works.
However this feels like bad practice since the user of the view modifier cannot control the animation properties since they are baked into the view modifier already. Furthermore the animation modifier .animation(.default) was deprecated in iOS 15 and its replacement .animation(.default, value: showBanner) also does not animate the show transition either.

Why does SwiftUI View background extend into safe area?

Here's a view that navigates to a 2nd view:
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink {
SecondView()
} label: {
Text("Go to 2nd view")
}
}
}
}
struct SecondView: View {
var body: some View {
ZStack {
Color.red
VStack {
Text("This is a test")
.background(.green)
//Spacer() // <--If you add this, it pushes the Text to the top, but its background no longer respects the safe area--why is this?
}
}
}
}
On the 2nd screen, the green background of the Text view only extends to its border, which makes sense because the Text is a pull-in view:
Now, if you add Spacer() after the Text(), the Text view pushes to the top of the VStack, which makes sense, but why does its green background suddenly push into the safe area?
In the Apple documentation here, it says:
By default, SwiftUI sizes and positions views to avoid system defined
safe areas to ensure that system content or the edges of the device
won’t obstruct your views. If your design calls for the background to
extend to the screen edges, use the ignoresSafeArea(_:edges:) modifier
to override the default.
However, in this case, I'm not using ignoresSafeArea, so why is it acting as if I did, rather than perform the default like Apple says, which is to avoid the safe areas?
You think that you use old background modifier version.
But actually, the above code uses a new one, introduced in iOS 15 version of background modifier which by default ignores all safe area edges:
func background<S>(_ style: S, ignoresSafeAreaEdges edges: Edge.Set = .all) -> some View where S : ShapeStyle
To fix the issue just replace .background(.green) with .background(Color.green) if your app deployment target < iOS 15.0, otherwise use a new modifier: .background { Color.green }.

How can I update modifier in SwiftUI without re drawing views

I want update my modifier in this way that the orignal content does not get redrawen or re appear for that goal I am using this code:
struct BigModified: ViewModifier {
func body(content: Content) -> some View {
return content
.font(Font.title.bold())
}
}
struct ContentView: View {
#State private var start: Bool = false
var body: some View {
Text("Hello, world!")
.modifier(start ? BigModified() : EmptyModifier())
.onTapGesture { start.toggle() }
}
}
The error is:
Result values in '? :' expression have mismatching types 'BigModified' and 'EmptyModifier'
I know that I can use switch or if, but again those make view get appear again. Is there a way around to update modifier with a State wrapper like in question, also I should add and tell it is obvious that I can use a parameter inside a ViewModifier for that! But that is not my question, my question is about .modifier(T) itself, more looking some thing like .modifier(T, V, U, ...) be possible to use or build, which takes more than just one viewModifier.
You can declare a view modifier in another way, as a func of View, so you can act on your view based on a boolean parameter.
This is how it works:
Modifier:
extension View {
#ViewBuilder
func modifier(big: Bool) -> some View {
if big {
self
.font(Font.title.bold())
} else {
self
}
}
}
How to use it:
var body: some View {
Text("Hello, world!")
.modifier(big: start)
.onTapGesture { start.toggle() }
}

NavigationView frame glitch with safearea

I try to create a custom ViewModifier similar to SwiftUI's .sheet modifier.
When I try to make a NavigationView spring from bottom, the frame of the view just glitched over safearea. The frame looks as if adjust to the safearea when the view moves from bottom to top.
Anyone knows maybe how to constrain the view frame inside the navigation view to avoid this?
Here is what happened. When click the plus button, the SwiftUI .sheet modifier shows up. Custom popup shows up when pressing the gear button.
Problem gif recording here
Here is code of the custom popup view.
struct SettingsView: View {
#Binding var showingSelf: Bool
#Binding var retryWrongCards: Bool
var body: some View {
GeometryReader { geometry in
NavigationView {
List {
Section {
Toggle(isOn: $retryWrongCards) {
Text("Retry Wrong Cards")
}
}
}
.animation(nil)
.navigationViewStyle(StackNavigationViewStyle())
.listStyle(GroupedListStyle())
.navigationBarTitle("Settings")
.navigationBarItems(trailing: Button("Done") {
self.showingSelf = false
})
}
}
}
}
Here's the code of custom modifier
struct Popup<T: View>: ViewModifier {
let popup: T
let isPresented: Bool
init(isPresented: Bool, #ViewBuilder content: () -> T) {
self.isPresented = isPresented
popup = content()
}
func body(content: Content) -> some View {
content
.overlay(popupContent())
}
#ViewBuilder private func popupContent() -> some View {
GeometryReader { geometry in
if isPresented {
popup
.animation(.spring())
.transition(.offset(x: 0, y: geometry.belowScreenEdge))
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
}
}
private extension GeometryProxy {
var belowScreenEdge: CGFloat {
UIScreen.main.bounds.height - frame(in: .global).minY
}
}
After debugging and trying many changes, I was already preparing a mini-project to file a SwiftUI bug for Apple. In the end, it was not necessary and we have a simple solution 🥳!!
TLDR; your SettingsView does not correctly initialise the NavigationView.
The modifier .navigationViewStyle(StackNavigationViewStyle()) has to be applied onto the NavigationView and not inside it (in contrast to navigationBarTitle or navigationBarItems which only work when put inside the NavigationView):
NavigationView {
List {
Section {
Toggle(isOn: $retryWrongCards) {
Text("Retry Wrong Cards")
}
}
}
.animation(nil)
.listStyle(GroupedListStyle())
.navigationBarTitle("Settings")
.navigationBarItems(trailing: Button("Done") {
self.showingSelf = false
})
}
.navigationViewStyle(StackNavigationViewStyle())
After this change your custom sheet modifier behaves for me identically as the regular .sheet modifier! Great work!
Philipp
It seems your animation does exactly what a sheet does. I would just replace it with that. You can easily present a sheet on top of a sheet.
Edit
After you posted your ViewModifier code, it seems the only difference is the animation type and the size of the popup.

SwiftUI: resetting TabView

I have a TabView with two tabs in a SwiftUI lifecycle app, one of them has complex view structure: NavigationView with a lot of sub-views inside, i.e.: NavigationLinks and their DestinationViews are spread on multiple levels down the view tree, each sub-view on its own is another view hierarchy with sheets and / or other DestinationViews. At some point inside this hierarchy, I want to reset the TabView to its original state which is displaying the first most view, so the user can restart their journey right at that state, as they were to open the app for the first time, so it's kinda impossible to track down something like isActive & isPresented bindings to pop-off or dismiss the views and sheets.
I thought of wrapping the TabView inside another view: RootView in an attempt to find an easy way to recreate that TabView from scratch or something like refreshing / resetting the TabView, but couldn't find a clew on how to do it.
Here's my code snippet:
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
}
}
struct RootView: View {
var body: some View {
ContentView()
}
}
struct ContentView: View {
var body: some View {
TabView { // <-- I need to reset it to its original state
View1() // <---- this view has complex view hierarchy
.tabItem {
Text("Home")
}.tag(0)
View2()
.tabItem {
Text("Settings")
}.tag(1)
}
}
}
p.s. I'm not looking for "popping off the view to root view", because this can't be done when there are many active NavigationLink destinations where the user might open one of the sheets and start a new navigation journey inside the sheet.
****** UPDATE ******
I've created a new Environment value to hold a boolean that should indicate whether the TabView should reset or not, and I've tracked every isPresented and isActive state variables in every view and reset them once that environment value is set to true like this:
struct ResetTabView: EnvironmentKey {
static var defaultValue: Binding<ResetTabObservable> = .constant(ResetTabObservable())
}
extension EnvironmentValues {
var resetTabView: Binding<ResetTabObservable> {
get { self[ResetTabView.self] }
set { self[ResetTabView.self] = newValue }
}
}
class ResetTabObservable: ObservableObject {
#Published var newValue = false
}
in every view that will present a sheet or push a new view I added something like this:
struct View3: View {
#State var showSheet = false
#Environment(\.resetTabView) var reset
var body: some View {
Text("This is view 3")
Button(action: {
showSheet = true
}, label: {
Text("show view 4")
})
.sheet(isPresented: $showSheet) {
View4()
}
.onReceive(reset.$newValue.wrappedValue, perform: { val in
if val == true {
showSheet = false
}
})
}
}
and in the last view (which will reset the TabView) I toggle the Environment value like this:
struct View5: View {
#Environment(\.resetTabView) var reset
var body: some View {
VStack {
Text("This is view 5")
Button(action: {
reset.newValue.wrappedValue = true
}, label: {
Text("reset tab view")
})
}
}
}
This resulted in awkward dismissal for views:
What i do for this is i make all my presentation bindings be stored using #SceneStorage("key") (instead of #State) this way they not only respect state restoration ! but you can also access them throughout your app easily by using the same key. This post gives a good example of how this enables the switching from Tab to Sidebar view on iPad.
I used this in my apps so if i have a button or something that needs to unwind many presentations it can read on all of these values and reset them back to wanted value without needing to pass around a load of bindings.