Start your A/B testing journey with SwiftUI

Last year, I shared a solution to tackle A/B testing on iOS in swift. Now that we have SwiftUI, I want to see if there is a better way to implement A/B testing. Starting from the same idea, I’ll share different implementations to find the best one.

ab-testing-swiftui

From the same idea of my first try with Reversi, the goal is to remove the pain points that other developers see during the implementation of A/B testing. In short, we don’t need to trade code quality or performance when we want to experiment features. Let’s see today what has changed with SwiftUI.

View Extension

Since we want to implement the variation on the design itself, my first idea was to extend the View protocol of SwiftUI. I started there because I don’t want to lose the ability to compose with other design functions. For instance, I have the idea that we should still be able to chain different variation.

From that extension, I want to execute a variation based on changing value, where would be a Binding one. My first try looked like this.

extension View {
    
    func withVariation(_ isEnabled: Binding<Bool>, closure: (Self) -> (Self)) -> Self {
        if isEnabled.wrappedValue {
            return closure(self)
        } else {
            return self
        }
    }
}

The code would be then chained to any ui component like other existing modifiers.

struct ContentView: View {
    
    @State var isEnabled: Bool = true

    var body: some View {

        VStack {

            Text("Hello World")
                .font(.system(.title, design: Font.Design.rounded))
                .withVariation($isEnabled) { 
                    $0.foregroundColor(.yellow)
                }

            Button("Switch variation 1", action: {
                self.variation1.toggle()
            })  
        }       
    }
}

This works just fine on the paper. We can make variation of design based on binded value, so if we have a ViewModel that handle which users has the variation, it’s quite fine.

However, in this solution, there are also couple limitation it’s better for you to know.

First, the order of chaining is very important in SwiftUI. If you do padding(10).cornerRadius(10) you won’t get the same result as if you do cornerRadius(10).padding(10). The same thing applied here.

The following doesn’t work, the text stays red.

Text("Hello World")
    .font(.system(.title, design: Font.Design.rounded))
    .foregroundColor(.red)
    .withVariation($isEnabled) { 
        $0.foregroundColor(.yellow)
    }

However, the opposite works just fine.

Text("Hello World")
    .font(.system(.title, design: Font.Design.rounded))
    .withVariation($isEnabled) { 
        $0.foregroundColor(.yellow)
    }
    .foregroundColor(.red)

This is due to order of execution of each modifier. The top one would be executed before the bottom one, so if you need to put any variation, then you’ll need to put them first if you have any other setup to do.

Other than that, it chained itself pretty nicely. So we can create more complex situation where we chain different variation.

Text("Hello World")
    .font(.system(.title, design: Font.Design.rounded))
    .withVariation($variation1) {
        $0.foregroundColor(.yellow)
    }
    .withVariation($variation2) {
        $0.bold()
    }
    .foregroundColor(.gray)

I would suggest to stay simple and create only one, I believe that most of the time we do A/B testing, we want to see what color, icon, or text copy convert the most. Let’s not go against A/B testing best practices and go crazy on number of the tests just because we can.

I’ve also couple doubt regarding the extension returning Self. Ideally, we should make sure it’s executed on main thread but there might be other cases I haven’t flagged yet.

Let’s see if we can find other ways to tackle the same idea.

Custom ViewModifier

With more research on SwiftUI, I found that a lot relies on modifier, especially the ViewModifier protocol. Foreach element passed, we can attach modifiers (represented as functions) that would modify the UI element when executed and pass to the next one. It seems matching better what I was looking for.

From the same idea, I try to create a struct to represent the variation I want to apply. I include a closure to execute when we want to modify the element.

struct Variation: ViewModifier {
    
    let closure: (Content) -> (Body)
    init(closure: @escaping (Content) -> (Body)) {
        self.closure = closure
    }
    
    public func body(content: Content) -> some View {
        return closure(content)
    }
}

This is where it starts getting tricky. Since View protocol used has associated type, I can’t use it as parameter directly. Therefore I’m using Content and Body to get back to a type I know, here Body being an associated type of View.

Then, I do a similar extension to View to be able to add a variation and chain them.

extension View {
    
    public func withVariation(isEnabled: Binding<Bool>, modifier: Variation) -> some View {
        Group {
            if isEnabled.wrappedValue {
                self.modifier(modifier)
            } else {
                self
            }
        }
    }
}

Here, I had to return a Group because I’m not actually returning the Self type but only modifying the current element. Without it, the compiler doesn’t understand what View I’m returning and display the following error.

Function declares an opaque return type, but has no return statements in its body from which to infer an underlying type.

So wrapping into a Group of one view trick him to know what’s going on.

Finally, I try to implement in the view the same as before.

Text("Hello World")
    .font(.system(.title, design: Font.Design.rounded))
    .withVariation(
        isEnabled: $variation1, 
        modifier: Variation(closure: {
            $0.foregroundColor(.yellow)
        })
    )

That’s where it went south. This code above doesn’t work. It’s due to the way the modifier is applied and how it should return some View but where I returns a different type due to Body associated type.

Cannot convert value of type ‘some View’ to closure result type ‘Variation.Body’ (aka ‘some View’).

From here, I tried different angles to tweak either the Variation modifier or the implementation of the extension but either the variation wasn’t executed or the error popped.

It looks to me that this restriction in the implementation could be by design where the ui element should be modifier inline, so that we know what to expect and how it should behave.

So I chose to go back to something simpler and define custom variations foreach of them. That still allows me to reuse and chain it but with more code.

// create two variations
public struct ColorVariation: ViewModifier {
    
    let color: Color
    init(color: Color) {
        self.color = color
    }
    
    public func body(content: Content) -> some View {
        return content
            .foregroundColor(color)    
    }
}

public struct SizeVariation: ViewModifier {
    
    let scale: CGFloat
    
    init(scale: CGFloat) {
        self.scale = scale
    }
    
    public func body(content: Content) -> some View {
        return content
        .scaleEffect(scale)
    }
}


// edit extension to include view modifiers
extension View {
    
    public func withVariation<T>(isEnabled: Binding<Bool>, modifier: T) -> some View where T: ViewModifier {
        Group {
            if isEnabled.wrappedValue {
                self.modifier(modifier) 
            } else {
                self
            }
        }
    }
}

From this setup, we can finally go back to our same experience from our first try.

Text("Hello World")
    .font(.system(.title, design: Font.Design.rounded))
    .withVariation(
        isEnabled: $variation1, 
        modifier: ColorVariation(color: .yellow))
    .withVariation(
        isEnabled: $variation2, 
        modifier: SizeVariation(scale: 2.0))
    .foregroundColor(.gray)

We can finally combine our experiment, customize them when needed and potentially reuse them if the variation is simple.


That’s it, we saw different ways to implement A/B testing with SwiftUI, specifically focus on creating variation using Apple new UI declarative framework. There are pros and cons of each solution but I feel there are less trade off compare the my first try with Reversi last year which makes me think it will get easier and easier over the time.

Thanks for reading

© 2023 Benoit Pasquier. All Rights Reserved
Author's picture

Benoit Pasquier

Software Engineer πŸ‡«πŸ‡·, writing about career development, mobile engineering and self-improvement

ShopBack πŸ’°

Singapore πŸ‡ΈπŸ‡¬