Skip to the content.

[iOS 17, macOS 14] Symbols + Private API

Symbols Framework를 UIKit/AppKit의 Private API로 용도를 확장하는 방법을 소개합니다.

Symbols 소개

UIKit

UIButtonConfiguration에 적용하기

Objective-C를 사용합니다.

SymbolButtonConfiguration

UIMenu + UIAction에 적용하기

Objective-C++를 사용합니다.

#import <UIKit/UIKit.h>
#import <objc/message.h>
#import <objc/runtime.h>
#import <vector>
#import <string>
#import <algorithm>
#import <ranges>

#pragma mark - Swizzling _UIContextMenuListView

/*
UIMenu는 내부적으로 UICollectionView를 사용한다. Cell에 UIAction의 속성에 맞게 업데이트 된 이후에 UIImageView에 Symbol Effect를 추가한다.
Symbol Effect는 UIAction에 할당된 Association Object에서 가져온다.
*/

namespace _UIContextMenuListView {
    namespace _configureCell_forElement_section_size {
        std::uint8_t *associationKey = nullptr;
        
        void (*original)(id, SEL, __kindof UICollectionViewCell *, __kindof UIMenuElement *, id, NSUInteger);
        void custom(id self, SEL _cmd, __kindof UICollectionViewCell *cell, __kindof UIMenuElement *element, id section, NSUInteger size) {
            original(self, _cmd, cell, element, section, size);
            
            auto symbolEffect = static_cast<__kindof NSSymbolEffect * _Nullable>(objc_getAssociatedObject(element, associationKey));
            if (!symbolEffect) return;
            
            // _UIContextMenuCellContentView
            __kindof UIView *contentView = cell.contentView;
            
            auto imageViews = std::vector<std::string> {
                "decorationImageView",
                "iconImageView",
                "emphasizediconImageView"
            } |
            std::views::transform([contentView](const std::string name) {
                return reinterpret_cast<id (*)(id, SEL)>(objc_msgSend)(contentView, sel_registerName(name.data()));
            });
            
            std::for_each(imageViews.begin(), imageViews.end(), [symbolEffect](UIImageView * _Nullable imageView) {
                if (!imageView) return;
                
                NSAutoreleasePool *pool = [NSAutoreleasePool new];
                
                [imageView addSymbolEffect:symbolEffect
                                   options:[NSSymbolEffectOptions optionsWithRepeating]
                                  animated:YES];
                
                [pool release];
            });
        }
    }
}

@implementation AppDelegate

+ (void)load {
    Method method_2 = class_getInstanceMethod(UIAction.class, @selector(copyWithZone:));
    _UIAction::copyWithZone::original = reinterpret_cast<id (*)(id, SEL, struct _NSZone *)>(method_getImplementation(method_2));
    method_setImplementation(method_2, reinterpret_cast<IMP>(_UIAction::copyWithZone::custom));
    
    //
 
    Method method_3 = class_getInstanceMethod(UIAction.class, NSSelectorFromString(@"_immutableCopy"));
    _UIAction::_immutableCopy::original = reinterpret_cast<id (*)(id, SEL)>(method_getImplementation(method_3));
    method_setImplementation(method_3, reinterpret_cast<IMP>(_UIAction::_immutableCopy::custom));
}

@end


#pragma mark - Swizzling UIAction

/*
UIAction이 복사될 때 Association Object도 복사되도록 한다.
*/

namespace _UIAction {
    namespace copyWithZone {
        id (*original)(id, SEL, struct _NSZone *);
        id custom(id self, SEL _cmd, struct _NSZone *zone) {
            id copy = original(self, _cmd, zone);
            
            id object = objc_getAssociatedObject(self, _UIContextMenuListView::_configureCell_forElement_section_size::associationKey);
            objc_setAssociatedObject(copy,
                                     _UIContextMenuListView::_configureCell_forElement_section_size::associationKey,
                                     object,
                                     OBJC_ASSOCIATION_COPY);
            
            return copy;
        }
    }
    
    namespace _immutableCopy {
        id (*original)(id, SEL);
        id custom(id self, SEL _cmd) {
            id copy = original(self, _cmd);
            
            id object = objc_getAssociatedObject(self, _UIContextMenuListView::_configureCell_forElement_section_size::associationKey);
            objc_setAssociatedObject(copy,
                                     _UIContextMenuListView::_configureCell_forElement_section_size::associationKey,
                                     object,
                                     OBJC_ASSOCIATION_COPY);
            
            return copy;
        }
    }
}

@implementation AppDelegate

+ (void)load {
    _UIContextMenuListView::_configureCell_forElement_section_size::associationKey = new std::uint8_t;
    
    Method method_1 = class_getInstanceMethod(NSClassFromString(@"_UIContextMenuListView"), NSSelectorFromString(@"_configureCell:forElement:section:size:"));
    _UIContextMenuListView::_configureCell_forElement_section_size::original = reinterpret_cast<void (*)(id, SEL, __kindof UICollectionViewCell *, __kindof UIMenuElement *, id, NSUInteger)>(method_getImplementation(method_1));
    method_setImplementation(method_1, reinterpret_cast<IMP>(_UIContextMenuListView::_configureCell_forElement_section_size::custom));
}

@end


#pragme mark - UIMenu 설정

UIAction *action = /* */;

objc_setAssociatedObject(action,
                         _UIContextMenuListView::_configureCell_forElement_section_size::associationKey,
                         [[NSSymbolBounceEffect bounceUpEffect] effectWithByLayer],
                         OBJC_ASSOCIATION_COPY);
                         
UIMenu *menu = [UIMenu menuWithChildren:@[action]];

AppKit

Objective-C++를 사용합니다.

NSButton에 적용하기

Symbol Effect만 지원합니다. Transition은 지원하지 않습니다.

#import <objc/message.h>

NSButton *button = /* Symbol Effect를 넣을 NSButton */;
button.image = /* Symbol Effect를 지원하는 NSImage */;

NSSymbolEffect *symbolEffect = /* 적용할 Symbol Effect */;
NSSymbolEffectOptions *options = /* Symbol Effect의 Options */;

auto cell = static_cast<NSButtonCell *>(button.cell);

// NSButtonImageView (<- _NSStoredImageSimpleImageView <- _NSSimpleImageView)
auto buttonImageView = reinterpret_cast<__kindof NSView * (*)(NSButtonCell *, SEL)>(objc_msgSend)(cell, NSSelectorFromString(@"_buttonImageView"));

// -[_NSSimpleImageView addSymbolEffect:options:animated:] 호출
reinterpret_cast<void (*)(__kindof NSView *, SEL, NSSymbolEffect *, NSSymbolEffectOptions *, BOOL)>(objc_msgSend)(buttonImageView, @selector(addSymbolEffect:options:animated:), symbolEffect, options, YES);

NSToolbar에 적용하기

마찬가지로 Symbol Effect만 지원하며 Transition은 지원하지 않습니다.

#import <objc/message.h>

NSToolbarItem *toolbarItem = /* Symbol Effect를 넣을 NSToolbarItem */;

toolbarItem.image = /* Symbol Effect를 지원하는 NSImage */;

NSSymbolEffect *symbolEffect = /* 적용할 Symbol Effect */;
NSSymbolEffectOptions *options = /* Symbol Effect의 Options */;

// NSToolbarButton (<- NSButton)
auto _view = reinterpret_cast<NSButton * (*)(id, SEL)>(objc_msgSend)(toolbarItem, NSSelectorFromString(@"_view"));

// _NSToolbarButtonCell (<- NSButtonCell)
auto cell = static_cast<__kindof NSButtonCell *>(_view.cell);

// _NSToolbarButtonCell는 NSImageView를 필요할 때만 lazy하게 불러옵니다. effect를 적용하기 위해서는 당장 필요하니 불러옵니다.
// frame이 zero일 경우 내부적으로 NSIsEmptyRect로 인해 NSImageView가 안 불러오는 로직이기에, zero가 아닌 값을 주입합니다.
reinterpret_cast<void (*)(id, SEL, struct CGRect, id)>(objc_msgSend)(cell, NSSelectorFromString(@"_updateImageViewWithFrame:inView:"), CGRectMake(0.f, 0.f, 1.f, 1.f), _view);

// NSButtonImageView (<- _NSStoredImageSimpleImageView <- _NSSimpleImageView)
auto buttonImageView = reinterpret_cast<__kindof NSView * (*)(NSButtonCell *, SEL)>(objc_msgSend)(cell, NSSelectorFromString(@"_buttonImageView"));

// -[_NSSimpleImageView addSymbolEffect:options:animated:] 호출
reinterpret_cast<void (*)(__kindof NSView *, SEL, NSSymbolEffect *, NSSymbolEffectOptions *, BOOL)>(objc_msgSend)(buttonImageView, @selector(addSymbolEffect:options:animated:), symbolEffect, options, YES);

NSMenuItem에 적용하기

Symbol Effect와 Transition 모두 지원합니다.

#import <objc/message.h>
#import <objc/runtime.h>

namespace NSMenuItemView {
    namespace _applyImage_withImageSize {
        static void (*original)(id, SEL, NSImage *, struct CGSize);
        
        static void custom(id self, SEL _cmd, NSImage *image, struct CGSize imageSize) {
            original(self, _cmd, image, imageSize);
            
            NSImageView * _Nullable imageView = nullptr;
            object_getInstanceVariable(self, "_imageView", reinterpret_cast<void **>(&imageView));
            
            if (imageView) {
                NSSymbolEffect *symbolEffect = /* 적용할 Symbol Effect */;
                NSSymbolEffectOptions *options = /* Symbol Effect의 Options */;
                
                [imageView addSymbolEffect:symbolEffect options:options animated:YES];
            }
        }
    }
}

@implementation AppDelegate

+ (void)load {
    Method method = class_getInstanceMethod(NSClassFromString(@"NSMenuItemView"), NSSelectorFromString(@"_applyImage:withImageSize:"));
    NSMenuItemView::_applyImage_withImageSize::original = reinterpret_cast<void (*)(id, SEL, NSImage *, struct CGSize)>(method_getImplementation(method));
    method_setImplementation(method, reinterpret_cast<IMP>(NSMenuItemView::_applyImage_withImageSize::custom));
}

@end

NSPaletteMenuItemView에 적용하기

Symbol Effect와 Transition 모두 지원합니다.

#pragma mark - Swizzling NSPaletteMenuItemView

/*
NSMenu는 내부적으로 NSTableView를 사용한다. View가 layout 될 때마다 NSImageView에 Symbol Effect를 추가한다.
Symbol Effect는 NSPaletteMenuItem에 할당된 Association Object에서 가져온다.
*/
namespace NSPaletteMenuItemView {
    namespace layout {
        static std::uint8_t *associationKey;
        static void (*original)(id, SEL);
        static void custom(id self, SEL _cmd) {
            original(self, _cmd);
            
            // NSPaletteMenuItem
            auto menuItem = reinterpret_cast<__kindof NSMenuItem * _Nullable (*)(id, SEL)>(objc_msgSend)(self, @selector(menuItem));
            NSSymbolEffect *effect = objc_getAssociatedObject(menuItem, associationKey);
            
            if (effect) {
                [static_cast<__kindof NSView *>(self).subviews enumerateObjectsUsingBlock:^(__kindof NSView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
                    if ([obj isKindOfClass:NSImageView.class]) {
                        auto imageView = static_cast<NSImageView *>(obj);
                        [imageView addSymbolEffect:effect options:[NSSymbolEffectOptions optionsWithRepeating] animated:YES];
                    }
                }];
            }
        }
    }
}

@implementation AppDelegate

+ (void)load {
    Method method_2 = class_getInstanceMethod(NSClassFromString(@"NSPaletteMenuItemView"), @selector(layout));
    NSPaletteMenuItemView::layout::original = reinterpret_cast<void (*)(id, SEL)>(method_getImplementation(method_2));
    method_setImplementation(method_2, reinterpret_cast<IMP>(NSPaletteMenuItemView::layout::custom));
}

@end

#pragma mark - NSMenu 설정

NSMenu *privatePaletteMenu = [NSMenu paletteMenuWithColors:@[]
                                                    titles:@[]
                                          selectionHandler:^(NSMenu * _Nonnull) {
    
}];

// NSPaletteMenuItem 생성
auto item = reinterpret_cast<__kindof NSMenuItem * (*)(id, SEL, id, id, id)>(objc_msgSend)([NSClassFromString(@"NSPaletteMenuItem") alloc], NSSelectorFromString(@"initWithColor:image:title:"), NSColor.redColor, [NSImage imageWithSystemSymbolName:@"star.square" accessibilityDescription:nil], nil);

// Effect 설정
objc_setAssociatedObject(item, NSPaletteMenuItemView::layout::associationKey, [[NSSymbolScaleEffect scaleUpEffect] effectWithWholeSymbol], OBJC_ASSOCIATION_COPY);