Conditionally apply modifier in SwiftUI

I'm trying to figure out an efficient way to conditionally apply modifiers within a swift view

In this example, I want to be able to turn off the overlay and tapGesture

struct TestView: View {
    var showOverlay:Bool = true
    var activeTap:Bool = true
    
    var body: some View {
        Text("Hello, World!")
            .overlay(Circle().foregroundColor(.red))
            .onTapGesture {
                print("tapped")
        }
    }
}

both the .overlay and .onTapGesture are extensions on View that return someView, so I figure I should be able to write a new conditional modifier something like

extension View {
    func ifTrue<T:View>(_ condition:Bool, apply:(T) -> T) -> some View {
        if condition {
            return apply(self) // 'Self' is not convertible to 'T'
        }
        else {
            return self
        }
    }
}

so my view would become something like

struct TestView: View {
    var showOverlay:Bool = true
    var activeTap:Bool = true
    
    var body: some View {
        Text("Hello, World!")
        .ifTrue(showOverlay){
              $0.overlay(Circle().foregroundColor(.red))
        }
        .ifTrue(activeTap){
            $0.onTapGesture { // Cannot invoke 'onTapGesture' with an argument list of type '(@escaping () ())'
                    print("tapped")
            }
        }
    }
}

I'm hitting two problems

#1 the view extension won't compile

return apply(self) // 'Self' is not convertible to 'T'

and the tapGesture isn't happy

$0.onTapGesture { // Cannot invoke 'onTapGesture' with an argument list of type '(@escaping () ())'

is there a sensible generic way to achieve conditional modifiers?
if not - how should I approach this?

thank you

1 Like

I can get this to (sort of) work using a bunch of AnyView casting

func ifTrue(_ condition:Bool, apply:(AnyView) -> (AnyView)) -> AnyView {
    if condition {
        return apply(AnyView(self))
    }
    else {
        return AnyView(self)
    }
}

and then

struct TestView: View {
    @State var showOverlay:Bool = true
    @State var activeTap:Bool = true
    
    var body: some View {
        Text("Hello, World!")
        .ifTrue(showOverlay){
              AnyView($0.overlay(Circle().foregroundColor(.red)))
        }
        .ifTrue(activeTap){
            AnyView($0.onTapGesture {
                self.showOverlay = !self.showOverlay
            })
        }
    }
}

this includes one or two conversions to AnyView which is ugly and (I believe) bad for performance

can anyone do better?

answering my own question here - I found this on StackOverflow

func `if`<Content: View>(_ conditional: Bool, content: (Self) -> Content) -> some View {
     if conditional {
         return AnyView(content(self))
     } else {
         return AnyView(self)
     }
 }

down to a single conversion to AnyView now, and cleaner usage

struct TestView: View {
    @State var showOverlay:Bool = true
    @State var activeTap:Bool = true
    
    var body: some View {
        Text("Hello, World!")
        .if(showOverlay){
              $0.overlay(Circle().foregroundColor(.red))
        }
        .if(activeTap){
            $0.onTapGesture {
                self.showOverlay = !self.showOverlay
            }
        }
    }
}
2 Likes

If you don't like AnyView you can also use TupleView with optionals. Is it faster? I have no idea. Same usage as in your last post

extension View {
    func `if`<Content: View>(_ conditional: Bool, content: (Self) -> Content) -> TupleView<(Self?, Content?)> {
        if conditional {
            return TupleView((nil, content(self)))
        } else {
            return TupleView((self, nil))
        }
    }
}

that's an interesting one.

zero docs on what TupleView does as far as I can see.

re performance - AnyView (kinda) warns about the view hierarchy being destroyed when the type of view in the AnyView changes. I'm guessing that a re-build when the conditional changes is inexpensive providing it isn't being done all the time.

presumably the TupleView would have to rebuild when it switches from one side of the tuple to the other anyway.

that's pure guesswork though, and I'm not even sure how I would measure performance...

Better one is to use ViewBuilder itself.

@ViewBuilder
var myView: some View {
  if condition {
    ViewA()
  } else {
    ViewB()
  }
}

This will use buildEither method and wrap both view into a SwiftUI internal view meant for conditions like that.

Keep in mind that SwiftUI might still wrap it into AnyView. You can workaround the issue by creating a generic Group alike type. And PLEASE don‘t abuse Group for the purpose of what ViewBuilder should do, it‘s not meant for these tasks.

5 Likes

@DevAndArtist can I use that to build a modifier, or are you suggesting that I should explicitly build the four possible options separately?

1 Like

Often you can find a way to make a modifier have no effect, although the way is different for each modifier. In your example:

  • You can set the overlay opacity to 0 to disable it.
  • You can create a TapGesture and add it with the .gesture modifier instead of using .onTapGesture. The .gesture modifier takes a GestureMask that you can use to disable the gesture.

Playground example:

import SwiftUI
import PlaygroundSupport

struct RootView: View {
    @State var counter: Int = 0
    @State var counterTapIsEnabled = true
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Count: \(counter)")
                .overlay(tapOverlay)
                .gesture(tapGesture, including: tapGestureMask)
            Button(buttonString) {
                self.counterTapIsEnabled.toggle()
            }
        }
    }
    
    private var tapOverlay: some View {
        return Circle()
            .foregroundColor(Color.red.opacity(overlayOpacity))
    }
    
    private var overlayOpacity: Double {
        return counterTapIsEnabled ? 0.2 : 0.0
    }
    
    private var tapGesture: some Gesture {
        return TapGesture()
            .onEnded { self.counter += 1 }
    }
    
    private var tapGestureMask: GestureMask {
        return counterTapIsEnabled ? .all : .subviews
    }
    
    private var buttonString: String {
        if counterTapIsEnabled {
            return "Turn Taps Off"
        } else {
            return "Turn Taps On"
        }
    }
}

PlaygroundPage.current.setLiveView(RootView())
2 Likes

What @mayoff suggests is the best alternative when possible. View updates will be more efficient, and automatic animations more robust, if you apply modifiers with values that conditionally take no effect, instead of conditionally applying or not applying the entire modifier. If that's not possible, what @DevAndArtist suggests would be the way to go. You should be able to build new modifiers using either technique, in addition to self-contained views.

9 Likes

@mayoff - thanks for that. One of the things that sent me on this hunt was the inability to find a null gesture. I wasn't aware of the gesture modifier option.
Thanks particularly for putting that together as a playground

@Joe_Groff I'm still not following the ViewBuilder approach. Is that a way to build a conditional modifier, or just a way to optionally build two different views?

are we just talking

@ViewBuilder
var myView: some View {
  if tappable {
    BaseView().padding().<more modifiers>.onTapGesture {
            //do something
        }
  } else {
    BaseView().padding().<more modifiers>
  }
}

(ignoring the gestureModifier for the sake of an example)
couple of things I don't like about this approach

  1. code repetition
  2. quickly gets unmanageable if there is more than one condition (e.g. 4 paths if two booleans)

or is there something I'm missing that would let me build something more like the if(condition,modifier) with this technique

I'm new to SwiftUI, so I may just not have the feel of it yet, but it does seem like everything is a special case.

I naturally want to be able to treat modifiers generically, and having a generic way to do an if seems like a normal thing to expect in my code.
However it seems that the design of SwiftUI wants me to treat each one as a special case and try to find a way to nullify it.

Am I missing the point?

The view builder language support is still under development, but in time, once we support variable bindings, you should be able to factor that into something like:

@ViewBuilder
var myView: some View {
  let view = BaseView().padding().<more modifiers>
  if tappable {
    view.onTapGesture {
            //do something
        }
  } else {
    view
  }
}

One way to think about it is that every statement in a view builder that executes, and isn't bound to a variable, is appended to the result view that gets rendered.

1 Like

Since Optional<G> conforms to Gesture if G conforms to Gesture, you can also handle the tap gesture like this:

private var tapGesture: some Gesture {
    let gesture = TapGesture().onEnded { self.counter += 1 }
    return counterTapIsEnabled ? gesture : nil
}

And then attach it with .gesture(tapGesture) and not use the mask.

3 Likes

@Joe_Groff thanks for the clarification

I guess the meta question this raises for me is why do I need to wait for you to build a special thing that allows me to conditionally modify a view in a somewhat generic way?

e.g. Why are views special things that need specific language artifacts (like @ViewBuilder) rather than just being amenable to standard swift functionality?

You wouldn't expect to build separate language features to let me modify an Int, change an array, or change the auth logic in an AF.request. What makes Views different fundamental things that need functionality at the language level rather than the code level?

1 Like

Just to be clearer on the motivation here - it's great to learn about tricks that I can use for different modifiers, but it would be neater to be able to handle a bunch of modifiers like so

    Card().padding().if(selected){
        $0.border(Color.red)
        .shadow(radius: 5)
        .gesture(myDragGesture())
    }

rather than individually

    Card().padding()
        .border(Color.red,width: selected ? 4 : 0 )
        .shadow(radius: selected : 5 ? 0 )
        .gesture(myOptionalDragGesture(selected:selected))
    }

(by the way - I can do this with the 'if' function from higher up, though there is some annoying AnyView action going on there)

Essentially, I'm back to generic vs specific here. I want to be able to treat all modifiers in the same way as bits of standard code rather than having a bunch of different special cases. Of course the fact that I want it doesn't have any bearing on reality...

A modifier is really a View that has the modified View as its child, and is thus part of the view hierarchy. SwiftUI intentionally embeds the structure of your view hierarchy into your program’s types for reasons Joe explained in this thread.

1 Like

@mayoff thanks for the pointer.

It's interesting to read about the motivation here (making it easy to diff/animate the view hierarchy)
It's beyond my skill to understand the tradeoffs here, but I'm still left feeling that (for possibly excellent reasons), we're left with an API where the primary constructs (Views) are second class citizens in the language

If View was a struct or a class, then we can pass it around, write logic, functions etc that work with it. It's a standard thing that I can program with.

e.g. there is nothing controversial or hard about writing

func if(condition:Bool,do:(UIView)-> UIView) -> UIView {...}

the equivalent with some View is much more tricky as the compiler doesn't allow

func if(condition:Bool,do:(some View)-> some View) -> some View ){...}

it's interesting to know that a modifier is really a View, but not as helpful as that would be if it were a class

Perhaps I'll get used to this. As I say, I'm fairly new to Swift UI. It's kinda magical to use - both in a bad way (confusing, particular, hard to reason about) and a good way (powerful functionality appears at minimal cost)

Is this what you want? This is similar to @cukr's version but avoids the TupleView wonkiness. It doesn't use AnyView either and I think it is the most efficient form (although I'm no expert on SwiftUI so I don't know if it is superior to the 'modifiers with nulled out parameters' way).

extension View {
	@ViewBuilder func `if`<T>(_ condition: Bool, transform: (Self) -> T) -> some View where T : View {
		if condition {
			transform(self)
		} else {
			self
		}
	}
}
13 Likes

View is a Protocol with Associated Type (PAT). PAT is already somewhat of a second class citizen so that’s probably true.

I’d say that ViewBuilder (which is still an experimental feature) makes it much easier to work with.

Still, you can that, writing logic/function like @bzamayo example, or passing concrete type of View around, albeit with slightly more difficulty.

1 Like

Individual Views are all struct values, that you can operate on in the usual ways through generic functions over the View protocol. One of the things about SwiftUI, though, is that it really prefers that you always return the exact same type of view. Early versions of SwiftUI did in fact have effectively a single View struct, and we found that this made the animation system in particular hard to use, because it was too easy to write things like:

condition ? Button().opacity().scale() : Button().scale().opacity()

which seem like they should work, but which are difficult to implement robustly in all cases, since the order of transformations and how they should be individually animated has to be reconstructed for all possible combinations. By basing the design instead on a protocol and generic wrapper structs, SwiftUI is intentionally trying to make the path of least resistance the one that gives the most robust results. The function builder magic is largely unnecessary, and beside the point to some degree; without it, you could still write if as a higher-order function, as @bzamayo noted , and we could construct compound views with regular constructors.

10 Likes

When you say 'we', it raises a question, how much involvement did you and other core members had in the framework besides related language features?

I‘m just curios. :slight_smile: