适配iOS26自定义UITabBarItem角标和小红点设置

系统自带的其实是够用的, 只是不符合产品需求 所以需要各种灵活的自定义手段~
诸如此类方法 https://huang-libo.github.io/posts/change-UITabBarItem-badge-size/
github一些开源框架都早已年久失修了,无法直接使用,还不如系统的"大胖点"省心省力~

需要魔改系统的一些接口或者增加一些花样, 其实还是挺折腾人的,系统更新可能就伴随着bug的出现, 开发者建议第一条,抛开好不好看不谈,能用系统自带的就用系统自带的吧~

以下代码是借助Gemini 3 Pro preview加持下完成的

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface UITabBar (CustomBadge)

/**
 显示小圆点(无数字)
 
 @param index  TabBarItem的索引 (0, 1, 2...)
 @param radius 圆点半径 (建议 4.0)
 @param offset 相对图标右上角的偏移量 (x正数向右, y正数向下)
 @param color  圆点颜色 (传nil默认红色)
 */
- (void)showRedDotAtIndex:(NSInteger)index
                   radius:(CGFloat)radius
                   offset:(CGPoint)offset
                    color:(nullable UIColor *)color;

/**
 显示带文字/数字的角标
 
 @param index  TabBarItem的索引
 @param text   显示的文字 (如 @"99", @"New")
 @param offset 相对图标右上角的偏移量
 @param configBlock 用于自定义样式的Block (可设置字体、背景、文字颜色等)
 */
- (void)showBadgeAtIndex:(NSInteger)index
                    text:(NSString *)text
                  offset:(CGPoint)offset
           configuration:(void (^ _Nullable)(UILabel *badgeLabel))configBlock;

/**
 隐藏指定位置的角标
 */
- (void)hideBadgeAtIndex:(NSInteger)index;

/**
 [重要] 更新所有角标位置
 请在 -viewDidLayoutSubviews 中调用此方法,以适配屏幕旋转或布局变化
 */
- (void)updateBadgePositions;

@end

NS_ASSUME_NONNULL_END

#import "UITabBar+CustomBadge.h"
#import <objc/runtime.h>

// 用于标记角标View的Tag基准值
static NSInteger const kCustomBadgeTagStart = 20000;
// 用于 Runtime 存储 offset 的 Key
static char kBadgeOffsetKey;

@implementation UITabBar (CustomBadge)

#pragma mark - Public Methods

- (void)showRedDotAtIndex:(NSInteger)index radius:(CGFloat)radius offset:(CGPoint)offset color:(UIColor *)color {
    [self removeBadgeOnItemIndex:index];
    
    UIView *badgeView = [[UIView alloc] init];
    badgeView.tag = kCustomBadgeTagStart + index;
    badgeView.layer.cornerRadius = radius;
    badgeView.backgroundColor = color ? : [UIColor redColor];
    badgeView.frame = CGRectMake(0, 0, radius * 2, radius * 2);
    badgeView.userInteractionEnabled = NO; // 防止拦截点击事件
    
    // 保存 offset 属性,以便布局变化时读取
    [self setOffset:offset forBadgeView:badgeView];
    
    [self addSubview:badgeView];
    [self bringSubviewToFront:badgeView];
    
    // 延迟一下,确保 TabBar 内部布局已完成
    dispatch_async(dispatch_get_main_queue(), ^{
        [self positionBadgeView:badgeView atIndex:index];
    });
}

- (void)showBadgeAtIndex:(NSInteger)index text:(NSString *)text offset:(CGPoint)offset configuration:(void (^)(UILabel * _Nonnull))configBlock {
    [self removeBadgeOnItemIndex:index];
    
    UILabel *badgeLabel = [[UILabel alloc] init];
    badgeLabel.tag = kCustomBadgeTagStart + index;
    badgeLabel.text = text;
    badgeLabel.textAlignment = NSTextAlignmentCenter;
    badgeLabel.clipsToBounds = YES;
    badgeLabel.userInteractionEnabled = NO;
    
    // 默认样式
    badgeLabel.backgroundColor = [UIColor redColor];
    badgeLabel.textColor = [UIColor whiteColor];
    badgeLabel.font = [UIFont systemFontOfSize:10 weight:UIFontWeightMedium];
    
    if (configBlock) {
        configBlock(badgeLabel);
    }
    
    // 自适应大小并处理圆角
    [badgeLabel sizeToFit];
    CGRect frame = badgeLabel.frame;
    frame.size.width += 8; // 左右内边距
    frame.size.height += 4; // 上下内边距
    // 保证最小为正圆
    if (frame.size.width < frame.size.height) {
        frame.size.width = frame.size.height;
    }
    badgeLabel.frame = frame;
    badgeLabel.layer.cornerRadius = frame.size.height / 2.0;
    
    // 保存 offset
    [self setOffset:offset forBadgeView:badgeLabel];
    
    [self addSubview:badgeLabel];
    [self bringSubviewToFront:badgeLabel];
    
    dispatch_async(dispatch_get_main_queue(), ^{
        [self positionBadgeView:badgeLabel atIndex:index];
    });
}

- (void)hideBadgeAtIndex:(NSInteger)index {
    [self removeBadgeOnItemIndex:index];
}

- (void)updateBadgePositions {
    for (UIView *subview in self.subviews) {
        if (subview.tag >= kCustomBadgeTagStart) {
            NSInteger index = subview.tag - kCustomBadgeTagStart;
            [self positionBadgeView:subview atIndex:index];
        }
    }
}

#pragma mark - Private Helpers

- (void)removeBadgeOnItemIndex:(NSInteger)index {
    UIView *badgeView = [self viewWithTag:kCustomBadgeTagStart + index];
    if (badgeView) {
        [badgeView removeFromSuperview];
    }
}

// 核心定位逻辑
- (void)positionBadgeView:(UIView *)view atIndex:(NSInteger)index {
    // 1. 获取对应 Index 的 Icon Frame
    CGRect iconFrame = [self getIconFrameAtIndex:index];
    
    // 如果没找到(可能 TabBar 还没布局好),暂时不处理
    if (CGRectEqualToRect(iconFrame, CGRectZero)) {
        return;
    }
    
    // 2. 获取保存的 Offset
    CGPoint offset = [self getOffsetFromBadgeView:view];
    
    // 3. 计算位置:Icon右上角 + Offset
    // iconFrame.maxX 是图标右侧边缘
    // iconFrame.minY 是图标顶部边缘
    CGFloat x = iconFrame.origin.x + iconFrame.size.width + offset.x;
    CGFloat y = iconFrame.origin.y + offset.y;
    
    // 4. 使用 center 定位,并加上 view 自身宽高的一半,使其 anchorPoint 看起来像是左上角或者中心
    // 这里我们简单处理:让 view 的中心点 = 计算出的坐标点
    view.center = CGPointMake(x, y);
}


#pragma mark - 核心定位逻辑适配 iOS 26+

// 查找系统内部图标的 Frame (适配 iOS 19+ / iOS 26)
- (CGRect)getIconFrameAtIndex:(NSInteger)index {
    // 1. 递归查找所有的 TabButton (兼容旧版 UITabBarButton 和新版 _UITabButton)
    NSMutableArray<UIView *> *allButtons = [NSMutableArray array];
    [self recursiveFindTabButtonsInView:self toArray:allButtons];
    
    // 2. 如果没找到任何按钮,返回空
    if (allButtons.count == 0) {
        return CGRectZero;
    }
    
    // 3. 按照 x 坐标从左到右排序
    [allButtons sortUsingComparator:^NSComparisonResult(UIView *view1, UIView *view2) {
        // 获取相对 window 或者 tabBar 的 convertRect 后的 x 坐标进行比较更稳妥
        CGRect frame1 = [view1 convertRect:view1.bounds toView:self];
        CGRect frame2 = [view2 convertRect:view2.bounds toView:self];
        return (frame1.origin.x < frame2.origin.x) ? NSOrderedAscending : NSOrderedDescending;
    }];
    
    // 4. 【关键步骤】去重逻辑
    // iOS 26 可能会有 SelectedContentView 和 ContentView,导致同一个 index 有两个重叠的 _UITabButton
    // 我们需要把位置非常接近的按钮视为同一个 Item
    NSMutableArray<UIView *> *uniqueButtons = [NSMutableArray array];
    UIView *lastButton = nil;
    for (UIView *btn in allButtons) {
        if (!lastButton) {
            [uniqueButtons addObject:btn];
            lastButton = btn;
        } else {
            CGRect lastFrame = [lastButton convertRect:lastButton.bounds toView:self];
            CGRect curFrame = [btn convertRect:btn.bounds toView:self];
            // 如果两个按钮水平中心点距离很近(例如小于 5pt),认为是同一个 index 的不同状态视图
            if (fabs(CGRectGetMidX(lastFrame) - CGRectGetMidX(curFrame)) > 5) {
                [uniqueButtons addObject:btn];
                lastButton = btn;
            } else {
                // 如果是重叠的,我们通常倾向于取“层级更高”或者“未隐藏”的那个,
                // 但为了计算坐标,只要 Frame 一样,取哪一个都行。
                // 这里不做处理,直接跳过。
            }
        }
    }
    
    // 5. 校验 Index 是否越界
    if (index < 0 || index >= uniqueButtons.count) {
        return CGRectZero;
    }
    
    UIView *targetButton = uniqueButtons[index];
    
    // 6. 在目标 Button 内部查找 UIImageView (icon)
    UIView *iconView = [self findFirstImageViewInView:targetButton];
    
    // 7. 坐标转换:将 icon 相对 button 的坐标转为相对 tabBar 的坐标
    if (iconView) {
        // 务必使用 convertRect,因为层级很深
        return [iconView convertRect:iconView.bounds toView:self];
    } else {
        // 兜底方案:如果找不到图,就用 button 中心
        CGRect btnFrame = [targetButton convertRect:targetButton.bounds toView:self];
        CGFloat w = 24; // 估算图标大小
        CGFloat h = 24;
        return CGRectMake(CGRectGetMidX(btnFrame) - w/2, CGRectGetMidY(btnFrame) - h/2, w, h);
    }
}

#pragma mark - 递归查找辅助方法

// 递归查找所有的 TabButton 容器
- (void)recursiveFindTabButtonsInView:(UIView *)view toArray:(NSMutableArray *)result {
    // 过滤隐藏的视图,避免计算干扰
    if (view.hidden || view.alpha < 0.01) {
        return;
    }
    
    NSString *className = NSStringFromClass(view.class);
    
    // 兼容旧版 "UITabBarButton" 和 新版 "_UITabButton"
    if ([className isEqualToString:@"UITabBarButton"] ||
        [className isEqualToString:@"_UITabButton"]) {
        [result addObject:view];
        // 找到按钮容器后,通常不需要再往里找子按钮了(避免嵌套导致重复),停止当前分支递归
        return;
    }
    
    // 特殊情况:如果按照你说的 _UITabButton 里面不再包含按钮,而是图片和文字,上面的逻辑是没问题的。
    // 继续遍历子视图
    for (UIView *subview in view.subviews) {
        [self recursiveFindTabButtonsInView:subview toArray:result];
    }
}

// 递归查找 Button 内部的 ImageView
- (UIImageView *)findFirstImageViewInView:(UIView *)view {
    // 过滤掉非图标的 Image,比如背景图或阴影
    // 通常 Icon 的尺寸较小,且具备 userInteractionEnabled = NO 等特征
    // 这里简单判断:如果是 UIImageView 且 size 适中
    if ([view isKindOfClass:[UIImageView class]]) {
        // 有些系统背景图是 UIImageView,需要过滤。
        // 经验判断:TabIcon 宽度通常在 20-40 之间,高度类似
        // 如果你的 TabBar 自定义了图片大小,这里可能需要调整
        if (view.bounds.size.width > 10 && view.bounds.size.height > 10) {
            return (UIImageView *)view;
        }
    }
    
    for (UIView *subview in view.subviews) {
        UIImageView *img = [self findFirstImageViewInView:subview];
        if (img) {
            return img;
        }
    }
    return nil;
}

#pragma mark - Runtime Associated Objects

- (void)setOffset:(CGPoint)offset forBadgeView:(UIView *)view {
    NSValue *offsetValue = [NSValue valueWithCGPoint:offset];
    objc_setAssociatedObject(view, &kBadgeOffsetKey, offsetValue, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (CGPoint)getOffsetFromBadgeView:(UIView *)view {
    NSValue *offsetValue = objc_getAssociatedObject(view, &kBadgeOffsetKey);
    return offsetValue ? [offsetValue CGPointValue] : CGPointZero;
}

@end

使用方法如下:

- (void)setupBadges {
    // 场景1:首页图标很大,红点需要往右上角偏移一点,不要压住图标
    [self.tabBar showRedDotAtIndex:0
                            radius:4.0
                            offset:CGPointMake(-3, 3) // 向左3,向下3(因为y向下是正,所以负数是向上)
                             color:nil];
    
    // 场景2:消息图标很小,红点需要往左下缩进一点,紧贴着
    [self.tabBar showBadgeAtIndex:1
                             text:@"99+"
                           offset:CGPointMake(3, 3) // 向左3,向下3
                    configuration:^(UILabel * _Nonnull badgeLabel) {
        badgeLabel.backgroundColor = [UIColor colorWithRed:1.0 green:0.3 blue:0.3 alpha:1];
        badgeLabel.font = [UIFont systemFontOfSize:9 weight:UIFontWeightBold];
    }];
}

posted @ 2025-11-23 11:46  CoderWGB  阅读(51)  评论(0)    收藏  举报