iOS开发基础9-文本提示、系统弹窗与自定义 HUD

iOS 提示框(HUD)全解析:文本提示、系统弹窗与自定义 HUD

提示框(HUD,Heads-Up Display)是为用户提供即时反馈的关键 UI 元素。本文详细介绍文本提示框、系统自带弹窗(UIAlertViewUIActionSheetUIAlertController)以及自定义 HUD 的实现与底层逻辑。


一、文本提示框

文本提示框通过一个 UILabel 以淡入淡出方式展示简短提示信息,适用于非阻塞式的轻量反馈。

实现思路

  1. 在视图中添加一个 UILabel 作为提示框,初始隐藏(alpha = 0)。
  2. 需要提示时,通过 UIView 动画将其淡入,延迟后淡出。

示例代码

Objective-C

@interface ViewController ()

@property (weak, nonatomic) IBOutlet UIView *shopsView;
@property (weak, nonatomic) IBOutlet UIButton *removeBtn;
@property (weak, nonatomic) IBOutlet UIButton *addBtn;
@property (weak, nonatomic) IBOutlet UILabel *hudLabel;
@property (nonatomic, strong) NSMutableArray *shops;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.hudLabel.alpha = 0.0; // 初始隐藏
}

#pragma mark - 按钮事件

- (IBAction)add {
    CGFloat shopWidth = 70;
    CGFloat shopHeight = 100;
    CGFloat colMargin = (self.shopsView.bounds.size.width - 3 * shopWidth) / 2;
    CGFloat rowMargin = colMargin;

    NSUInteger index = self.shopsView.subviews.count;
    NSInteger row = index / 3;
    NSInteger col = index % 3;
    CGFloat shopX = col * (shopWidth + colMargin);
    CGFloat shopY = row * (shopHeight + rowMargin);

    XMGShopView *shopView = [XMGShopView shopView];
    shopView.frame = CGRectMake(shopX, shopY, shopWidth, shopHeight);
    shopView.shop = self.shops[index];
    [self.shopsView addSubview:shopView];

    self.removeBtn.enabled = YES;
    self.addBtn.enabled = self.shopsView.subviews.count < self.shops.count;

    if (!self.addBtn.enabled) {
        [self showHUDWithText:@"商品柜已经满了,不要再买买买了..."];
    }
}

- (IBAction)remove {
    UIView *subView = self.shopsView.subviews.lastObject;
    [subView removeFromSuperview];

    self.removeBtn.enabled = self.shopsView.subviews.count > 0;
    self.addBtn.enabled = YES;

    if (!self.removeBtn.enabled) {
        [self showHUDWithText:@"商品柜已经空了,继续买买买..."];
    }
}

#pragma mark - HUD 显示

- (void)showHUDWithText:(NSString *)text {
    self.hudLabel.text = text;

    // 开始新动画前移除旧动画,避免连续调用时动画叠加
    [self.hudLabel.layer removeAllAnimations];
    self.hudLabel.alpha = 0.0;

    [UIView animateWithDuration:0.3 animations:^{
        self.hudLabel.alpha = 1.0;
    } completion:^(BOOL finished) {
        [UIView animateWithDuration:0.3 delay:2.0 options:kNilOptions animations:^{
            self.hudLabel.alpha = 0.0;
        } completion:nil];
    }];
}

#pragma mark - 懒加载

- (NSMutableArray *)shops {
    if (!_shops) {
        NSString *path = [[NSBundle mainBundle] pathForResource:@"shops" ofType:@"plist"];
        NSArray *tempArr = [NSArray arrayWithContentsOfFile:path];
        _shops = [NSMutableArray array];
        for (NSDictionary *dict in tempArr) {
            NJShop *shop = [NJShop shopWithDict:dict];
            [_shops addObject:shop];
        }
    }
    return _shops;
}

@end

Swift

class ViewController: UIViewController {

    @IBOutlet weak var shopsView: UIView!
    @IBOutlet weak var removeBtn: UIButton!
    @IBOutlet weak var addBtn: UIButton!
    @IBOutlet weak var hudLabel: UILabel!

    lazy var shops: [Shop] = {
        let path = Bundle.main.path(forResource: "shops", ofType: "plist")!
        let array = NSArray(contentsOfFile: path) as! [[String: String]]
        return array.map { Shop(dict: $0) }
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        hudLabel.alpha = 0
    }

    @IBAction func add() {
        let shopWidth: CGFloat = 70
        let shopHeight: CGFloat = 100
        let colMargin = (shopsView.bounds.width - 3 * shopWidth) / 2
        let rowMargin = colMargin

        let index = shopsView.subviews.count
        let row = index / 3
        let col = index % 3
        let shopX = CGFloat(col) * (shopWidth + colMargin)
        let shopY = CGFloat(row) * (shopHeight + rowMargin)

        let shopView = ShopView.shopView()
        shopView.frame = CGRect(x: shopX, y: shopY, width: shopWidth, height: shopHeight)
        shopView.shop = shops[index]
        shopsView.addSubview(shopView)

        removeBtn.isEnabled = true
        addBtn.isEnabled = shopsView.subviews.count < shops.count

        if !addBtn.isEnabled {
            showHUD(text: "商品柜已经满了,不要再买买买了...")
        }
    }

    @IBAction func remove() {
        shopsView.subviews.last?.removeFromSuperview()
        removeBtn.isEnabled = shopsView.subviews.count > 0
        addBtn.isEnabled = true

        if !removeBtn.isEnabled {
            showHUD(text: "商品柜已经空了,继续买买买...")
        }
    }

    private func showHUD(text: String) {
        hudLabel.text = text
        hudLabel.layer.removeAllAnimations()
        hudLabel.alpha = 0

        UIView.animate(withDuration: 0.3) {
            self.hudLabel.alpha = 1
        } completion: { _ in
            UIView.animate(withDuration: 0.3, delay: 2.0) {
                self.hudLabel.alpha = 0
            }
        }
    }
}

底层逻辑

  • 透明度动画:通过 alpha 属性的渐变实现淡入淡出,UIView 动画底层基于 Core Animation 的 CABasicAnimation
  • 避免动画叠加:连续调用时,先 removeAllAnimations 并重置 alpha,否则多个动画叠加会导致显示异常。
  • 非阻塞式:文本提示框不拦截用户交互,适合轻量提示。

二、系统自带提示框

1. UIAlertView(已废弃)

UIAlertView 是 iOS 8 之前的模态弹窗,iOS 8 起被废弃,由 UIAlertController 取代。新项目不应使用。

// ⚠️ 已废弃,仅作了解
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"标题"
                                                message:@"正文"
                                               delegate:self
                                      cancelButtonTitle:@"取消"
                                      otherButtonTitles:@"确定", nil];
alert.alertViewStyle = UIAlertViewStyleLoginAndPasswordInput; // 可带输入框
[alert show];

#pragma mark - UIAlertViewDelegate
- (void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex {
    if (buttonIndex == alertView.cancelButtonIndex) {
        NSLog(@"点击了取消");
    } else {
        NSLog(@"点击了确定");
    }
}

注意UIAlertView.delegateassignunsafe_unretained)而非 weak,若 delegate 在弹窗显示期间被释放,会导致悬垂指针崩溃。这也是其被废弃的原因之一。

2. UIActionSheet(已废弃)

UIActionSheet 用于底部弹出的多选项操作菜单,iOS 8 起同样被废弃

// ⚠️ 已废弃,仅作了解
UIActionSheet *sheet = [[UIActionSheet alloc] initWithTitle:@"选择操作"
                                                   delegate:self
                                          cancelButtonTitle:@"取消"
                                     destructiveButtonTitle:@"删除"
                                          otherButtonTitles:@"分享", @"编辑", nil];
[sheet showInView:self.view];

destructiveButtonTitle 对应红色警告按钮,应用于"删除"等危险操作,而非普通的"确定"。

3. UIAlertController(推荐)

UIAlertController 是 iOS 8 引入的统一弹窗 API,同时替代了 UIAlertViewUIActionSheet,通过 preferredStyle 区分样式。

Alert 样式(居中弹窗)

Objective-C
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"标题"
                                                               message:@"正文"
                                                        preferredStyle:UIAlertControllerStyleAlert];

// 按钮
UIAlertAction *okAction = [UIAlertAction actionWithTitle:@"确定"
                                                   style:UIAlertActionStyleDefault
                                                 handler:^(UIAlertAction *action) {
    NSLog(@"点击了确定");
}];

UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:@"取消"
                                                       style:UIAlertActionStyleCancel
                                                     handler:^(UIAlertAction *action) {
    NSLog(@"点击了取消");
}];

UIAlertAction *deleteAction = [UIAlertAction actionWithTitle:@"删除"
                                                       style:UIAlertActionStyleDestructive
                                                     handler:^(UIAlertAction *action) {
    NSLog(@"点击了删除");
}];

[alert addAction:okAction];
[alert addAction:cancelAction];
[alert addAction:deleteAction];

// 添加文本输入框(Alert 样式下最多支持 2 个输入框)
[alert addTextFieldWithConfigurationHandler:^(UITextField *textField) {
    textField.placeholder = @"用户名";
}];
[alert addTextFieldWithConfigurationHandler:^(UITextField *textField) {
    textField.secureTextEntry = YES;
    textField.placeholder = @"密码";
}];

[self presentViewController:alert animated:YES completion:nil];
Swift
let alert = UIAlertController(title: "标题", message: "正文", preferredStyle: .alert)

alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in
    print("点击了确定")
})
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { _ in
    print("点击了删除")
})

alert.addTextField { textField in
    textField.placeholder = "用户名"
}
alert.addTextField { textField in
    textField.isSecureTextEntry = true
    textField.placeholder = "密码"
}

present(alert, animated: true)

ActionSheet 样式(底部弹出菜单)

Objective-C
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"选择操作"
                                                               message:nil
                                                        preferredStyle:UIAlertControllerStyleActionSheet];

[sheet addAction:[UIAlertAction actionWithTitle:@"拍照" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) {
    NSLog(@"拍照");
}]];
[sheet addAction:[UIAlertAction actionWithTitle:@"从相册选择" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) {
    NSLog(@"从相册选择");
}]];
[sheet addAction:[UIAlertAction actionWithTitle:@"取消" style:UIAlertActionStyleCancel handler:nil]];

// iPad 上 ActionSheet 必须通过 popover 展示,否则崩溃
if (sheet.popoverPresentationController) {
    sheet.popoverPresentationController.sourceView = self.view;
    sheet.popoverPresentationController.sourceRect = CGRectMake(self.view.bounds.size.width / 2, self.view.bounds.size.height, 0, 0);
}

[self presentViewController:sheet animated:YES completion:nil];
Swift
let sheet = UIAlertController(title: "选择操作", message: nil, preferredStyle: .actionSheet)

sheet.addAction(UIAlertAction(title: "拍照", style: .default) { _ in print("拍照") })
sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { _ in print("从相册选择") })
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))

// iPad 适配
if let popover = sheet.popoverPresentationController {
    popover.sourceView = view
    popover.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.maxY, width: 0, height: 0)
}

present(sheet, animated: true)

UIAlertAction 样式

样式 说明
UIAlertActionStyleDefault 默认样式(蓝色文字)
UIAlertActionStyleCancel 取消样式(加粗文字,固定在底部/右侧)
UIAlertActionStyleDestructive 警告样式(红色文字,用于删除等危险操作)

底层逻辑

  • 统一控制器UIAlertController 本质是一个 UIViewController,通过 presentViewController:animated:completion: 模态展示,其内部管理一个 UIAlertController.view
  • Block 回调:通过 handler block 处理按钮点击,避免了 delegate 模式的状态分散和悬垂指针问题。
  • iPad 适配:ActionSheet 样式在 iPad 上必须以 popover 形式展示,需配置 popoverPresentationController.sourceView/sourceRect,否则会因缺少锚点而崩溃。
  • 输入框限制:Alert 样式最多支持添加两个文本输入框(系统限制),ActionSheet 样式不支持添加输入框。

三、自定义提示框(HUD)

当系统弹窗无法满足需求时(如加载中指示器、自定义样式提示),可以自定义 HUD。

实现思路

  1. 创建一个覆盖全屏的半透明背景视图,拦截用户交互。
  2. 在中心添加 UIActivityIndicatorView(菊花)和 UILabel(提示文字)。
  3. 提供显示和移除方法。

封装工具类

Objective-C

// HUDManager.h
#import <UIKit/UIKit.h>

@interface HUDManager : NSObject

/// 在指定视图上显示加载 HUD
+ (void)showLoadingInView:(UIView *)view text:(NSString *)text;

/// 隐藏指定视图上的 HUD
+ (void)hideInView:(UIView *)view;

/// 显示纯文本提示(自动消失)
+ (void)showText:(NSString *)text inView:(UIView *)view;

@end
// HUDManager.m
#import "HUDManager.h"

static const NSInteger kHUDTag = 99999;

@implementation HUDManager

+ (void)showLoadingInView:(UIView *)view text:(NSString *)text {
    // 避免重复添加
    if ([view viewWithTag:kHUDTag]) return;

    // 全屏半透明背景,拦截触摸事件
    UIView *hud = [[UIView alloc] initWithFrame:view.bounds];
    hud.tag = kHUDTag;
    hud.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.3];
    hud.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;

    // 中心提示框
    UIView *contentView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 150, 120)];
    contentView.center = CGPointMake(hud.bounds.size.width / 2, hud.bounds.size.height / 2);
    contentView.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.7];
    contentView.layer.cornerRadius = 10;
    contentView.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin |
                                   UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin;

    // 菊花
    UIActivityIndicatorView *activity = [[UIActivityIndicatorView alloc]
                                         initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge];
    activity.center = CGPointMake(contentView.bounds.size.width / 2, 40);
    [activity startAnimating];
    [contentView addSubview:activity];

    // 文字
    UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(0, 75, contentView.bounds.size.width, 30)];
    label.text = text;
    label.textColor = [UIColor whiteColor];
    label.textAlignment = NSTextAlignmentCenter;
    label.font = [UIFont systemFontOfSize:14];
    [contentView addSubview:label];

    [hud addSubview:contentView];
    [view addSubview:hud];
}

+ (void)hideInView:(UIView *)view {
    UIView *hud = [view viewWithTag:kHUDTag];
    [hud removeFromSuperview];
}

+ (void)showText:(NSString *)text inView:(UIView *)view {
    if ([view viewWithTag:kHUDTag]) return;

    UILabel *label = [[UILabel alloc] init];
    label.tag = kHUDTag;
    label.text = text;
    label.textColor = [UIColor whiteColor];
    label.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.7];
    label.textAlignment = NSTextAlignmentCenter;
    label.font = [UIFont systemFontOfSize:14];
    label.numberOfLines = 0;
    label.layer.cornerRadius = 8;
    label.clipsToBounds = YES;

    CGSize size = [text boundingRectWithSize:CGSizeMake(250, CGFLOAT_MAX)
                                     options:NSStringDrawingUsesLineFragmentOrigin
                                  attributes:@{NSFontAttributeName: label.font}
                                     context:nil].size;
    label.bounds = CGRectMake(0, 0, size.width + 40, size.height + 20);
    label.center = CGPointMake(view.bounds.size.width / 2, view.bounds.size.height / 2);
    label.alpha = 0;

    [view addSubview:label];

    [UIView animateWithDuration:0.3 animations:^{
        label.alpha = 1.0;
    } completion:^(BOOL finished) {
        [UIView animateWithDuration:0.3 delay:1.5 options:kNilOptions animations:^{
            label.alpha = 0;
        } completion:^(BOOL finished) {
            [label removeFromSuperview];
        }];
    }];
}

@end

Swift

class HUDManager {
    private static let hudTag = 99999

    static func showLoading(in view: UIView, text: String) {
        guard view.viewWithTag(hudTag) == nil else { return }

        let hud = UIView(frame: view.bounds)
        hud.tag = hudTag
        hud.backgroundColor = UIColor.black.withAlphaComponent(0.3)
        hud.autoresizingMask = [.flexibleWidth, .flexibleHeight]

        let contentView = UIView(frame: CGRect(x: 0, y: 0, width: 150, height: 120))
        contentView.center = CGPoint(x: hud.bounds.midX, y: hud.bounds.midY)
        contentView.backgroundColor = UIColor.black.withAlphaComponent(0.7)
        contentView.layer.cornerRadius = 10
        contentView.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin,
                                        .flexibleTopMargin, .flexibleBottomMargin]

        let activity = UIActivityIndicatorView(style: .large)
        activity.center = CGPoint(x: contentView.bounds.midX, y: 40)
        activity.startAnimating()
        contentView.addSubview(activity)

        let label = UILabel(frame: CGRect(x: 0, y: 75, width: contentView.bounds.width, height: 30))
        label.text = text
        label.textColor = .white
        label.textAlignment = .center
        label.font = .systemFont(ofSize: 14)
        contentView.addSubview(label)

        hud.addSubview(contentView)
        view.addSubview(hud)
    }

    static func hide(in view: UIView) {
        view.viewWithTag(hudTag)?.removeFromSuperview()
    }

    static func showText(_ text: String, in view: UIView) {
        guard view.viewWithTag(hudTag) == nil else { return }

        let label = UILabel()
        label.tag = hudTag
        label.text = text
        label.textColor = .white
        label.backgroundColor = UIColor.black.withAlphaComponent(0.7)
        label.textAlignment = .center
        label.font = .systemFont(ofSize: 14)
        label.numberOfLines = 0
        label.layer.cornerRadius = 8
        label.clipsToBounds = true

        let size = (text as NSString).boundingRect(
            with: CGSize(width: 250, height: .greatestFiniteMagnitude),
            options: .usesLineFragmentOrigin,
            attributes: [.font: label.font!],
            context: nil
        ).size
        label.bounds = CGRect(x: 0, y: 0, width: size.width + 40, height: size.height + 20)
        label.center = CGPoint(x: view.bounds.midX, y: view.bounds.midY)
        label.alpha = 0

        view.addSubview(label)

        UIView.animate(withDuration: 0.3) {
            label.alpha = 1
        } completion: { _ in
            UIView.animate(withDuration: 0.3, delay: 1.5) {
                label.alpha = 0
            } completion: { _ in
                label.removeFromSuperview()
            }
        }
    }
}

使用示例

// 显示加载
[HUDManager showLoadingInView:self.view text:@"正在拼命加载中..."];

// 加载完成后隐藏
[HUDManager hideInView:self.view];

// 纯文本提示(自动消失)
[HUDManager showText:@"操作成功" inView:self.view];
HUDManager.showLoading(in: view, text: "正在拼命加载中...")
HUDManager.hide(in: view)
HUDManager.showText("操作成功", in: view)

主流第三方库

实际项目中通常不重复造轮子,推荐使用成熟的第三方 HUD 库:

特点
MBProgressHUD 最流行的 HUD 库,支持加载、文本、进度条、自定义视图,功能全面
SVProgressHUD 全局单例,调用极简([SVProgressHUD show]),适合全局提示
JGProgressHUD 现代化、高度可定制,支持毛玻璃效果

底层逻辑

  • 全屏拦截:HUD 背景覆盖整个父视图并拦截触摸事件,防止用户在加载期间操作界面。
  • 添加到 Window 还是 View:如果需要 HUD 不随当前视图消失(如网络请求期间用户切换页面),应添加到 UIWindow 上;如果仅在当前页面有效,添加到当前 view 即可。
  • autoresizingMask:设置合适的自动伸缩掩码,确保父视图尺寸变化(如旋转)时 HUD 仍居中覆盖。
  • 避免重复添加:通过 tag 检查是否已存在 HUD,防止多次调用导致叠加。

四、总结

类型 适用场景 是否阻塞交互 推荐度
文本提示框(UILabel) 轻量、非阻塞的简短提示 适合自定义场景
UIAlertView / UIActionSheet 已废弃,仅兼容旧代码 不推荐
UIAlertController(Alert) 居中模态弹窗、需要用户确认 推荐
UIAlertController(ActionSheet) 底部多选项操作菜单 推荐
自定义 HUD 加载中、进度、自定义样式提示 可配置 推荐(或用第三方库)
  • 新项目统一使用 UIAlertController,避免使用已废弃的 UIAlertView / UIActionSheet
  • 加载提示优先考虑 MBProgressHUD 等成熟库,减少重复开发。
  • 文本提示框注意连续调用时的动画叠加问题,应在开始新动画前移除旧动画。
posted @ 2015-07-17 22:37  Mr.陳  阅读(1019)  评论(0)    收藏  举报