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分类根据颜色生成背景图片。

浙公网安备 33010602011771号