XCode Previews with Feature Gating

In ShipKit I’m rolling out a free tier that includes limited use of features in the app. Thanks to this I’ll need to do a lot of testing between variations of the experience based on which feature set the user has active. In this (inagural!) post I share how I accomplish this with a handy ViewModifier for configuring views while working with Previews.

Here is the final product–a small View with a configured Previews that show it under two different states representing a “free” and a “pro” feature set:

struct ExampleView: View {
	@Environment(FeatureGatingService.self) featureGatingService

	var body: some View = {
		VStack {
			if featureGatingService.canAddMorePackages() {
				Button("Track another package") {
					showPackageTrackingSheet.toggle()
				}
			} else {
				Text("Upgrade to Pro to track more packages")
			}
		}
	}
}

#Preview {
	ExampleView()
		.setupPreview(featureSet: .free)
}

#Preview {
	ExampleView()
		.setupPreview(featureSet: .pro)
}

Follow along below to see how I get there.

Feature Set and Feature Service

I represent the features that a user has access to in a FeatureSet struct that describes which features are available. It looks like the following, with two static values representing pre-configured sets of features:

struct FeatureSet: Hashable, Equatable {
    var name: String
    var `description`: String
    var numPackages: Int
    var period: Calendar.Component
    var notificationsEnabled: Bool
    var emailEnabled: Bool

    static var free: FeatureSet {
        .init(
            name: "Basic",
            description: "Basic tracking for 3 packages",
            numPackages: 3,
            period: .month,
            notificationsEnabled: false,
            emailEnabled: false
        )
    }

    static var pro: FeatureSet {
        .init(
            name: "Pro",
            description: "All features and 100 packages a month",
            numPackages: 100,
            period: .month,
            notificationsEnabled: true,
            emailEnabled: true
        )
    }
}

My FeatureGatingService is the object that I inject from the top level of the app using @Environment, and it contains logic that looks at AppStorage and other data sources to determine whether a user can perform an action:

@Observable
final class FeatureGatingService {
	var featureSet: FeatureSet

	init(featureSet: FeatureSet = .pro) {
		self.featureSet = featureSet
	}

	func canAddMorePackages() -> Bool {
		let numPackagesAdded = getNumberOfPackagesAddedThisMonth()

		return numPackagesAdded + 1 < featureSet.numPackages
	}
}

Across the app I check to see if the user can add packages with checks in featureGatingService.canAddMorePackages(). Sometimes I’ll display a Paywall if the answer is false, or sometimes I will change the UI. Consider this example where I conditionally show a button if the user can still add packages to the app:

struct ExampleView: View {
	@Environment(FeatureGatingService.self) featureGatingService

	var body: some View = {
		VStack {
			if featureGatingService.canAddMorePackages() {
				Button("Track another package") {
					showPackageTrackingSheet.toggle()
				}
			} else {
				Text("Upgrade to Pro to track more packages")
			}
		}
	}
}

To preview this, I’ll use SwiftUI’s new Preview macros along with a custom ViewModifier called setupPreviews(featureSet:) to make it easy to switch between the two states:

#Preview {
	ExampleView()
		.setupPreview(featureSet: .free)
}

#Preview {
	ExampleView()
		.setupPreview(featureSet: .pro)
}

The ViewModifier

The ViewModifer is a pretty simple one which injects the FeatureGatingService into the view. The modifier also takes a FeatureSet which is what powers the entire feature gating system from before. The view extension defaults the feature set to the “pro” features for convenience, but it’s easy to change it as the case dictates while building a new View.

struct PreviewSetupViewModifier: ViewModifier {
    var featureSet: FeatureSet

    func body(content: Content) -> some View {
        return content
            .environment(FeatureGatingService(featureSet: featureSet))
    }
}

extension View {
    func setupPreview(featureSet: FeatureSet = .pro) -> some View {
        return modifier(PreviewSetupViewModifier(featureSet: featureSet))
    }
}

This is a helpful pattern for all sorts of cases where you’d want to control the behavior of views from these dependency-injected objects. I use it to initialize SwiftData models for testing, inject mock API services, and more. I hope you found this useful!