iOS开发基础27-导航控制器深度解析:入栈出栈、导航条定制、控制器生命周期与微博个人主页效果

导航控制器深度解析:入栈出栈、导航条定制、控制器生命周期与微博个人主页效果

本文系统梳理 UINavigationController 的入栈出栈机制、导航条内容定制、控制器完整视图生命周期,并通过微博个人详情页效果(滚动渐变导航栏、头部缩放)的完整封装实践,掌握导航控制器的高级用法。


一、导航控制器的入栈与出栈

1. initWithRootViewController 的本质

initWithRootViewController: 创建导航控制器并设置根控制器。其底层实现是直接设置 viewControllers 数组,将根控制器作为导航栈的第一个元素,而非调用 pushViewController:animated::

UIViewController *rootVC = [[OneViewController alloc] init];
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:rootVC];
// 底层等价于:nav.viewControllers = @[rootVC];
// 不是调用 push,不会触发 push 动画和转场代理方法

pushViewController:animated: 会触发转场动画、调用 navigationController:willShowViewController:animated: 和 didShowViewController:animated: 代理方法;initWithRootViewController: 只是初始化导航栈,无动画和代理回调。

2. 导航栈管理属性

// 导航栈中的所有控制器(数组顺序:底部 → 顶部)
NSArray<UIViewController *> *stack = self.navigationController.viewControllers;

// 栈顶控制器(当前显示的控制器,不包含模态弹出的)
UIViewController *topVC = self.navigationController.topViewController;

// 可见控制器(可能是 topViewController,也可能是模态弹出的控制器)
UIViewController *visibleVC = self.navigationController.visibleViewController;

3. 入栈操作(push)

- (IBAction)pushToNext:(id)sender {
    TwoViewController *two = [[TwoViewController alloc] init];
    // 将控制器压入导航栈,从右侧滑入
    [self.navigationController pushViewController:two animated:YES];
}

push 后,新控制器成为 topViewController,原控制器仍在导航栈中(viewControllers 数组的倒数第二个)。

4. 出栈操作(pop)

// 返回上一个控制器
- (IBAction)popBack:(id)sender {
    [self.navigationController popViewControllerAnimated:YES];
}

// 返回到根控制器(弹出所有非根控制器)
- (IBAction)popToRoot:(id)sender {
    [self.navigationController popToRootViewControllerAnimated:YES];
}

// 返回到导航栈中的指定控制器
- (IBAction)popToSpecific:(id)sender {
    for (UIViewController *vc in self.navigationController.viewControllers) {
        if ([vc isKindOfClass:[OneViewController class]]) {
            [self.navigationController popToViewController:vc animated:YES];
            break;
        }
    }
}

pop 后,被弹出的控制器从 viewControllers 数组移除,引用计数 -1。如果没有其他强引用,控制器会在动画结束后释放。popViewControllerAnimated: 返回被弹出的控制器。

5. 导航控制器代理

@interface ViewController () <UINavigationControllerDelegate>
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.navigationController.delegate = self;
}

// 即将显示某个控制器
- (void)navigationController:(UINavigationController *)navigationController willShowViewController:(UIViewController *)viewController animated:(BOOL)animated {
    NSLog(@"即将显示: %@", NSStringFromClass([viewController class]));
}

// 已经显示某个控制器
- (void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated {
    NSLog(@"已经显示: %@", NSStringFromClass([viewController class]));
}

// 自定义转场动画(返回非 nil 时使用自定义动画)
- (id<UIViewControllerAnimatedTransitioning>)navigationController:(UINavigationController *)navigationController animationControllerForOperation:(UINavigationControllerOperation)operation fromViewController:(UIViewController *)fromVC toViewController:(UIViewController *)toVC {
    return nil; // 返回自定义转场对象
}

@end

二、设置导航条内容

导航条(UINavigationBar)的内容由每个控制器的 UINavigationItem 控制,按钮由 UIBarButtonItem 创建。

1. 标题与标题视图

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 方式一:文本标题
    self.title = @"个人主页";
    // 等价于 self.navigationItem.title = @"个人主页";
    
    // 方式二:自定义标题视图(如图片、搜索框)
    UIImageView *logoView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"logo"]];
    [logoView sizeToFit];
    self.navigationItem.titleView = logoView;
}

2. 左右按钮

// 单个右侧按钮(文本)
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"完成"
                                                                             style:UIBarButtonItemStyleDone
                                                                            target:self
                                                                            action:@selector(doneClicked)];

// 单个右侧按钮(图片,保持原始颜色不被 tintColor 影响)
UIImage *icon = [[UIImage imageNamed:@"share"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal];
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithImage:icon
                                                                             style:UIBarButtonItemStylePlain
                                                                            target:self
                                                                            action:@selector(shareClicked)];

// 多个右侧按钮
UIBarButtonItem *shareItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAction target:self action:@selector(share)];
UIBarButtonItem *addItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAdd target:self action:@selector(add)];
self.navigationItem.rightBarButtonItems = @[addItem, shareItem];

// 左侧按钮
self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"菜单"
                                                                            style:UIBarButtonItemStylePlain
                                                                           target:self
                                                                           action:@selector(menuClicked)];

3. 返回按钮控制

// 隐藏系统返回按钮
self.navigationItem.hidesBackButton = YES;

// 自定义返回按钮(会替换系统返回按钮,不影响 pop 手势)
self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"back"]
                                                                            style:UIBarButtonItemStylePlain
                                                                           target:self
                                                                           action:@selector(backClicked)];

// 设置下一个页面的返回按钮文字(在当前页面设置,影响 push 进来的下一个页面)
self.navigationItem.backBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"返回"
                                                                           style:UIBarButtonItemStylePlain
                                                                          target:nil
                                                                          action:nil];

自定义 leftBarButtonItem 会替换系统返回按钮,边缘滑动返回手势(interactivePopGestureRecognizer)默认仍生效。如果需要禁用手势:self.navigationController.interactivePopGestureRecognizer.enabled = NO;

4. 导航栏整体外观

旧方式(iOS 13 之前)

// 导航栏背景色
[self.navigationController.navigationBar setBarTintColor:[UIColor whiteColor]];
// 标题颜色
[self.navigationController.navigationBar setTitleTextAttributes:@{NSForegroundColorAttributeName: [UIColor blackColor]}];
// 按钮颜色(tintColor)
[self.navigationController.navigationBar setTintColor:[UIColor systemBlueColor]];
// 透明导航栏(设置空图片背景)
[self.navigationController.navigationBar setBackgroundImage:[[UIImage alloc] init] forBarMetrics:UIBarMetricsDefault];
[self.navigationController.navigationBar setShadowImage:[[UIImage alloc] init]]; // 去除底部分割线

现代方式(iOS 13+:UINavigationBarAppearance)

if (@available(iOS 13.0, *)) {
    UINavigationBarAppearance *appearance = [[UINavigationBarAppearance alloc] init];
    [appearance configureWithOpaqueBackground]; // 不透明背景
    // [appearance configureWithTransparentBackground]; // 透明背景
    
    appearance.backgroundColor = [UIColor whiteColor];
    appearance.titleTextAttributes = @{NSForegroundColorAttributeName: [UIColor blackColor]};
    appearance.shadowColor = [UIColor clearColor]; // 去除底部分割线
    
    // standardAppearance:滚动到非顶部时的外观
    self.navigationController.navigationBar.standardAppearance = appearance;
    // scrollEdgeAppearance:滚动到顶部(内容边缘与导航栏对齐)时的外观
    self.navigationController.navigationBar.scrollEdgeAppearance = appearance;
    // compactAppearance:紧凑尺寸(如横屏 iPhone)
    self.navigationController.navigationBar.compactAppearance = appearance;
}

5. 大标题模式(iOS 11+)

// 在导航控制器中开启大标题
self.navigationController.navigationBar.prefersLargeTitles = YES;

// 在具体控制器中控制是否使用大标题
self.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeAlways; // 总是大标题
// UINavigationItemLargeTitleDisplayModeNever:从不
// UINavigationItemLargeTitleDisplayModeAutomatic:自动(继承上一个页面)

6. 导航栏隐藏与显示

// 隐藏导航栏(带动画)
[self.navigationController setNavigationBarHidden:YES animated:YES];
// 显示导航栏
[self.navigationController setNavigationBarHidden:NO animated:YES];

三、控制器的视图生命周期

1. 完整生命周期流程

init (或 initWithCoder: / initWithNibName:bundle:)
    ↓
loadView (创建控制器的 view,纯代码重写此方法手动创建 view)
    ↓
viewDidLoad (view 加载完成,最常用的初始化入口,只调用一次)
    ↓
viewWillAppear: (view 即将显示,每次出现都会调用)
    ↓
viewWillLayoutSubviews (即将布局子视图,可能多次调用)
    ↓
viewDidLayoutSubviews (子视图布局完成,frame 已确定,可能多次调用)
    ↓
viewDidAppear: (view 完全显示,每次出现都会调用)
    ↓
    ... (用户交互) ...
    ↓
viewWillDisappear: (view 即将消失,每次消失都会调用)
    ↓
viewDidDisappear: (view 完全消失,每次消失都会调用)
    ↓
dealloc (控制器销毁,清理资源)

2. 各方法最佳使用场景

方法 调用次数 最佳使用场景
viewDidLoad 1 次 添加子视图、加载数据、初始化属性、设置约束
viewWillAppear: 多次 刷新数据、更新 UI 状态、开始动画
viewWillLayoutSubviews 多次 布局前的准备(很少重写)
viewDidLayoutSubviews 多次 设置子视图 frame(frame 已确定)、圆角、渐变层
viewDidAppear: 多次 开始持续动画、统计页面曝光
viewWillDisappear: 多次 保存数据、停止动画、结束编辑
viewDidDisappear: 多次 停止耗时操作、移除通知(也可在 dealloc)
dealloc 1 次 移除 KVO/通知、invalidate 定时器、释放资源

3. 注意事项

  • viewDidLoad 中 frame 可能未确定:此时 view 的 frame 可能还是 XIB 中的初始值,Auto Layout 约束尚未布局完成。涉及 frame 计算的操作应放在 viewDidLayoutSubviews 中。
  • viewWillLayoutSubviews / viewDidLayoutSubviews 可能多次调用:视图尺寸变化(如旋转、键盘弹出、约束更新)都会触发,不要在其中做一次性初始化。
  • viewWillAppear: / viewDidAppear: 每次出现都调用:从后台返回、从下一个页面 pop 回来都会触发,适合刷新数据但不适合一次性初始化。
  • dealloc 中不要访问 view:此时 view 可能已释放,访问 self.view 会触发重新加载(懒加载),导致异常。

四、封装实现微博个人详情页效果

实现效果:滚动时导航栏背景从透明渐变为白色、标题文字从透明渐变为黑色、顶部头部视图随下拉放大、上推缩小。

1. 核心原理

  • UITableView 设置顶部 contentInset,将头部视图放在 tableView 上方。
  • 监听 scrollViewDidScroll:,根据滚动偏移量计算:
    • 头部视图高度(下拉放大、上推缩小)
    • 导航栏背景透明度(从 0 → 1)
    • 标题文字透明度(从 0 → 1)
  • 导航栏设置透明背景(空图片),滚动时动态设置带颜色的背景图片。

2. 完整实现代码

ViewController.h

#import <UIKit/UIKit.h>

@interface ViewController : UIViewController

@property (weak, nonatomic) IBOutlet UITableView *tableView;
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *headerHeightConstraint; // 头部视图高度约束

@end

ViewController.m

#import "ViewController.h"
#import "UIImage+Image.h"

static const CGFloat kHeaderHeight = 200;      // 头部初始高度
static const CGFloat kMinHeaderHeight = 64;     // 头部最小高度(导航栏高度)
static const CGFloat kSegmentHeight = 44;        // 分段控件高度

@interface ViewController () <UITableViewDelegate, UITableViewDataSource>

@property (nonatomic, assign) CGFloat originalOffsetY; // 初始偏移量
@property (nonatomic, weak) UILabel *titleLabel;       // 自定义标题标签(控制透明度)

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // iOS 11+:禁止自动调整 ScrollView 的 inset
    if (@available(iOS 11.0, *)) {
        self.tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
    } else {
        self.automaticallyAdjustsScrollViewInsets = NO;
    }
    
    // 初始偏移量 = -(头部高度 + 分段高度)
    _originalOffsetY = -(kHeaderHeight + kSegmentHeight);
    
    // 设置 tableView 顶部 inset,为头部视图留出空间
    self.tableView.contentInset = UIEdgeInsetsMake(kHeaderHeight + kSegmentHeight, 0, 0, 0);
    
    // 清空导航栏背景和阴影(实现透明导航栏)
    [self.navigationController.navigationBar setBackgroundImage:[[UIImage alloc] init] forBarMetrics:UIBarMetricsDefault];
    [self.navigationController.navigationBar setShadowImage:[[UIImage alloc] init]];
    
    // 自定义标题视图(UILabel,用于控制透明度)
    UILabel *titleLabel = [[UILabel alloc] init];
    titleLabel.text = @"个人主页";
    titleLabel.textColor = [UIColor colorWithWhite:0 alpha:0]; // 初始透明
    [titleLabel sizeToFit];
    self.navigationItem.titleView = titleLabel;
    self.titleLabel = titleLabel;
}

#pragma mark - UITableViewDataSource

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return 20;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    static NSString *cellID = @"cell";
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellID];
    if (!cell) {
        cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cellID];
    }
    cell.textLabel.text = [NSString stringWithFormat:@"第 %ld 行", (long)indexPath.row];
    return cell;
}

#pragma mark - UITableViewDelegate (UIScrollViewDelegate)

- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
    CGFloat offsetY = scrollView.contentOffset.y;
    
    // 偏移量差值(相对于初始位置)
    CGFloat delta = offsetY - _originalOffsetY;
    
    // 1. 计算头部视图新高度
    CGFloat newHeaderHeight = kHeaderHeight - delta;
    if (newHeaderHeight < kMinHeaderHeight) {
        newHeaderHeight = kMinHeaderHeight; // 最小高度限制
    }
    self.headerHeightConstraint.constant = newHeaderHeight;
    
    // 2. 计算导航栏背景透明度(滚动到最小高度时完全不透明)
    CGFloat alpha = delta / (kHeaderHeight - kMinHeaderHeight);
    if (alpha > 1) alpha = 1;      // 最大 1(完全不透明)
    if (alpha < 0) alpha = 0;      // 最小 0(完全透明)
    
    // 3. 设置导航栏背景(根据颜色生成图片)
    UIColor *bgColor = [UIColor colorWithWhite:1 alpha:alpha];
    [self.navigationController.navigationBar setBackgroundImage:[UIImage imageWithColor:bgColor] forBarMetrics:UIBarMetricsDefault];
    
    // 4. 设置标题文字透明度
    self.titleLabel.textColor = [UIColor colorWithWhite:0 alpha:alpha];
}

@end

3. 根据颜色生成图片的扩展类

UIImage+Image.h

#import <UIKit/UIKit.h>

@interface UIImage (Image)

// 根据颜色生成指定尺寸的图片(默认 1x1,考虑 Retina 屏幕 scale)
+ (UIImage *)imageWithColor:(UIColor *)color;
+ (UIImage *)imageWithColor:(UIColor *)color size:(CGSize)size;

@end

UIImage+Image.m

#import "UIImage+Image.h"

@implementation UIImage (Image)

+ (UIImage *)imageWithColor:(UIColor *)color {
    return [self imageWithColor:color size:CGSizeMake(1, 1)];
}

+ (UIImage *)imageWithColor:(UIColor *)color size:(CGSize)size {
    // 使用 UIGraphicsBeginImageContextWithOptions,第三个参数 0 表示使用设备主屏幕 scale(Retina 优化)
    UIGraphicsBeginImageContextWithOptions(size, NO, 0);
    CGContextRef context = UIGraphicsGetCurrentContext();
    
    CGContextSetFillColorWithColor(context, [color CGColor]);
    CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
    
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    
    return image;
}

@end

UIGraphicsBeginImageContext(size) 不考虑 Retina 屏幕的 scale,生成的图片在 Retina 屏幕上会模糊。应使用 UIGraphicsBeginImageContextWithOptions(size, NO, 0)(0 表示自动使用设备主屏幕的 scale)。

iOS 10+ 推荐使用 UIGraphicsImageRenderer:

UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:size];
UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext * _Nonnull rendererContext) {
    [color setFill];
    UIRectFill(CGRectMake(0, 0, size.width, size.height));
}];

4. 实现效果说明

滚动方向 头部视图 导航栏背景 标题文字
初始位置 高度 200 完全透明 完全透明
向上推(delta 增大) 高度缩小,最小 64 透明度从 0 → 1 透明度从 0 → 1
向下拉(delta 减小) 高度放大(下拉放大效果) 保持透明 保持透明
滚动到最小高度 高度 64 完全白色不透明 完全黑色可见

五、Swift 版本对照

导航控制器基本操作

let rootVC = OneViewController()
let nav = UINavigationController(rootViewController: rootVC)
window?.rootViewController = nav
window?.makeKeyAndVisible()

// push
let two = TwoViewController()
navigationController?.pushViewController(two, animated: true)

// pop
navigationController?.popViewController(animated: true)
navigationController?.popToRootViewController(animated: true)

// 导航栈
let stack = navigationController?.viewControllers
let topVC = navigationController?.topViewController

导航条设置

// 标题
title = "个人主页"
navigationItem.titleView = UIImageView(image: UIImage(named: "logo"))

// 按钮
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(done))
navigationItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(named: "back"), style: .plain, target: self, action: #selector(back))
navigationItem.rightBarButtonItems = [
    UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(add)),
    UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(share))
]

// 隐藏返回按钮
navigationItem.hidesBackButton = true

// 大标题
navigationController?.navigationBar.prefersLargeTitles = true
navigationItem.largeTitleDisplayMode = .always

// iOS 13+ 外观
if #available(iOS 13.0, *) {
    let appearance = UINavigationBarAppearance()
    appearance.configureWithOpaqueBackground()
    appearance.backgroundColor = .white
    appearance.titleTextAttributes = [.foregroundColor: UIColor.black]
    navigationController?.navigationBar.standardAppearance = appearance
    navigationController?.navigationBar.scrollEdgeAppearance = appearance
}

微博个人主页效果(Swift)

class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {

    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var headerHeightConstraint: NSLayoutConstraint!
    
    private let kHeaderHeight: CGFloat = 200
    private let kMinHeaderHeight: CGFloat = 64
    private let kSegmentHeight: CGFloat = 44
    private var originalOffsetY: CGFloat = 0
    private weak var titleLabel: UILabel?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        if #available(iOS 11.0, *) {
            tableView.contentInsetAdjustmentBehavior = .never
        } else {
            automaticallyAdjustsScrollViewInsets = false
        }
        
        originalOffsetY = -(kHeaderHeight + kSegmentHeight)
        tableView.contentInset = UIEdgeInsets(top: kHeaderHeight + kSegmentHeight, left: 0, bottom: 0, right: 0)
        
        // 透明导航栏
        navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default)
        navigationController?.navigationBar.shadowImage = UIImage()
        
        // 自定义标题
        let label = UILabel()
        label.text = "个人主页"
        label.textColor = UIColor(white: 0, alpha: 0)
        label.sizeToFit()
        navigationItem.titleView = label
        titleLabel = label
    }
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let delta = scrollView.contentOffset.y - originalOffsetY
        
        // 头部高度
        var newHeight = kHeaderHeight - delta
        newHeight = max(kMinHeaderHeight, newHeight)
        headerHeightConstraint.constant = newHeight
        
        // 透明度
        var alpha = delta / (kHeaderHeight - kMinHeaderHeight)
        alpha = min(1, max(0, alpha))
        
        // 导航栏背景
        let bgColor = UIColor(white: 1, alpha: alpha)
        navigationController?.navigationBar.setBackgroundImage(UIImage(color: bgColor), for: .default)
        
        // 标题透明度
        titleLabel?.textColor = UIColor(white: 0, alpha: alpha)
    }
}

extension UIImage {
    convenience init(color: UIColor, size: CGSize = CGSize(width: 1, height: 1)) {
        let renderer = UIGraphicsImageRenderer(size: size)
        let image = renderer.image { context in
            color.setFill()
            context.fill(CGRect(origin: .zero, size: size))
        }
        self.init(cgImage: image.cgImage!)
    }
}

六、总结

  • 入栈出栈:initWithRootViewController: 底层设置 viewControllers 数组(非 push);push 从右侧滑入,pop 从左侧滑出;popToViewController: 可返回到栈中任意控制器;导航控制器代理可监听显示事件和自定义转场。
  • 导航条定制:每个控制器通过 navigationItem 控制标题、左右按钮、返回按钮;UIBarButtonItem 创建按钮,UIImageRenderingModeAlwaysOriginal 保持图片原色;iOS 13+ 用 UINavigationBarAppearance 统一设置外观(standard/scrollEdge/compact)。
  • 生命周期:完整流程 init → loadView → viewDidLoad → viewWillAppear → viewWillLayoutSubviews → viewDidLayoutSubviews → viewDidAppear → viewWillDisappear → viewDidDisappear → dealloc;viewDidLoad 只调用一次适合初始化,viewDidLayoutSubviews 中 frame 已确定适合布局,dealloc 中清理资源。
  • 微博个人主页效果:通过 contentInset 为头部视图留空间,监听 scrollViewDidScroll: 计算偏移量差值,动态调整头部高度约束、导航栏背景图片透明度、标题文字透明度;导航栏初始设为空图片背景实现透明,滚动时用 UIImage+Image 分类根据颜色生成背景图片。

posted @ 2015-07-30 22:48  Mr.陳  阅读(644)  评论(0)    收藏  举报