Search code examples
swiftobjective-cuikituitraitcollection

How to implement custom traits in Objective-C?


The WWDC 2023 video Unleash the UIKit trait system discusses new UIKit APIs added in iOS 17 related to custom traits in trait collections. The video and its associated code is all in Swift but I wish to use these APIs in an older Objective-C app. I've run into some questions while converting the sample code from Swift to Objective-C.

To start, here is some sample Swift code provided on the Code tab (at timestamp 11:00) of the video:

enum MyAppTheme: Int {
    case standard, pastel, bold, monochrome
}

struct MyAppThemeTrait: UITraitDefinition {
    static let defaultValue = MyAppTheme.standard
    static let affectsColorAppearance = true
    static let name = "Theme"
    static let identifier = "com.myapp.theme"
}

extension UITraitCollection {
    var myAppTheme: MyAppTheme { self[MyAppThemeTrait.self] }
}

extension UIMutableTraits {
    var myAppTheme: MyAppTheme {
        get { self[MyAppThemeTrait.self] }
        set { self[MyAppThemeTrait.self] = newValue }
    }
}

So far, this is what I have for the Objective-C conversion:

MyTraits.h:

@import UIKit;

NS_ASSUME_NONNULL_BEGIN

typedef enum : NSUInteger {
    MyAppThemeStandard,
    MyAppThemePastel,
    MyAppThemeBold,
    MyAppThemeMonochrome
} MyAppTheme;

@interface MyAppThemeTrait : NSObject<UINSIntegerTraitDefinition>

@end

@interface UITraitCollection (MyTraits)

@property (nonatomic, readonly) MyAppTheme myAppTheme;

@end

NS_ASSUME_NONNULL_END

MyTraits.m:

#import "Traits.h"

@implementation MyAppThemeTrait

+ (NSInteger)defaultValue { return MyAppThemeStandard; }

+ (BOOL)affectsColorAppearance { return YES; }

+ (NSString *)name { return @"Theme"; }

+ (NSString *)identifier { return @"com.myapp.theme"; }

@end

@implementation UITraitCollection (MyTraits)

- (MyAppTheme)myAppTheme {
    return [self valueForNSIntegerTrait:MyAppThemeTrait.class];
}

@end

The sticky point here is how to implement the extension on the UIMutableTraits protocol. Objective-C doesn't support adding a computed property to a protocol through an extension/category. How do you finish implementing custom traits in Objective-C when the language doesn't support what is needed?


Solution

  • It turns out that you don't need the computed property on UIMutableTraits for this to work. That added property in the example Swift code is just a convenience for later use. In Objective-C, you can still make it work, it's just that the final code is slightly more cumbersome.

    For example, in the Swift code you can create a UITraitCollection with the sample custom trait using code such as:

    let myTraits = UITraitCollection { mutableTraits in
        mutableTraits.myAppTheme = .pastel // <-- custom trait here
        mutableTraits.horizontalSizeClass = .regular
    }
    

    In Objective-C, the code looks as follows:

    UITraitCollection *myTraits = [UITraitCollection traitCollectionWithTraits:^(id<UIMutableTraits>  _Nonnull mutableTraits) {
        [mutableTraits setNSIntegerValue:MyAppThemePastel forTrait:MyAppThemeTrait.class];
        mutableTraits.horizontalSizeClass = UIUserInterfaceSizeClassRegular;
    }];
    

    Since there is no convenience property named myAppTheme on UIMutableTraits, we need to use the more verbose syntax of calling setNSIntegerValue:forTrait: (or setObjectValue:forTrait: or setCGFloatValue:forTrait: as needed for the given trait).

    However, there is the added property myAppTheme on UITraitCollection so we can now read the value as follows:

    MyAppTheme theme = myTraits.myAppTheme;
    

    To see a fully working example in Objective-C, create a new iOS app project. Select Storyboard for the user interface and Objective-C for the language.

    Add MyTraits.h and MyTraits.m as shown in the question above.

    To work with the theme, we need to add a custom dynamic color. Add the following to MyTraits.h:

    @interface UIColor (MyTraits)
    
    @property (nonatomic, readonly, class) UIColor *customBackgroundColor;
    
    @end
    

    Add the following to MyTraits.m:

    @implementation UIColor (MyTraits)
    
    + (UIColor *)customBackgroundColor {
        return [UIColor colorWithDynamicProvider:^UIColor * _Nonnull(UITraitCollection * _Nonnull traitCollection) {
            // This example uses some randomly chosen colors. Pick your own to suit your needs
            switch (traitCollection.myAppTheme) {
                case MyAppThemeStandard:
                    return [UIColor colorWithRed:0 green:1 blue:0 alpha:1];
                case MyAppThemePastel:
                    return [UIColor colorWithRed:0.39 green:0.58 blue:0.93 alpha:1];
                case MyAppThemeBold:
                    return [UIColor colorWithRed:1 green:0 blue:1 alpha:1];
                case MyAppThemeMonochrome:
                    return [UIColor colorWithRed:0.3 green:0.3 blue:0.3 alpha:1];
                default: // make the compiler happy
                    return UIColor.systemBackgroundColor; // This shouldn't happen
            }
        }];
    }
    
    @end
    

    In ViewController.m, add the following code to viewDidLoad. This creates a button that overrides the myAppTheme trait with a random theme.

    // Let's see when the theme changes
    [self registerForTraitChanges:@[ MyAppThemeTrait.class ] withHandler:^(__kindof id<UITraitEnvironment>  _Nonnull traitEnvironment, UITraitCollection * _Nonnull previousCollection) {
        NSLog(@"VC change myAppTheme: %ld", traitEnvironment.traitCollection.myAppTheme);
    }];
    
    // Set the background to the custom theme color
    self.view.backgroundColor = UIColor.customBackgroundColor;
    
    // Setup a button to randomly change the theme
    __weak typeof(self) weakSelf = self;
    UIButtonConfiguration *cfg = [UIButtonConfiguration grayButtonConfiguration];
    cfg.title = @"Change";
    UIButton *btn = [UIButton buttonWithConfiguration:cfg primaryAction:[UIAction actionWithHandler:^(__kindof UIAction * _Nonnull action) {
        NSInteger theme = arc4random() % 4; // Pick a random theme
        // Set the new theme
        [weakSelf.traitOverrides setNSIntegerValue:theme forTrait:MyAppThemeTrait.class];
        // If we could add the convenience property to UIMutableTrait then this line would simply be:
        // weakSelf.traitOverrides.myAppTheme = theme;
    }]];
    btn.translatesAutoresizingMaskIntoConstraints = NO;
    [self.view addSubview:btn];
    
    // Put the button in the center of the screen
    [NSLayoutConstraint activateConstraints:@[
        [btn.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
        [btn.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor],
    ]];
    

    Build and run the app. Each time you tap the button the view controller's background color will change depending on the random theme that gets selected. You will also see a log message in the console showing what theme has been selected.