摘要: 本练习是传智播客 iOS UI 基础班的第八天课程练习, 对练习的知识点和重点进行总结. 实现效果如图所示
QQ 聊天界面 UI 练习
基本思路
导入素材, 创建主界面
实现字典转模型(可以暂时不计算控件的 frame), 并实现懒加载
创建自定义 Cell, 实现其创建单元格各控件的类方法和对象方法, 重写数据模型的 set 方法, 在该方法中对各控件的内容和 frame 进行赋值
实现 tableView 的 datasource 方法
在 frame 模型中计算各控件的 frame
修正 tableView 的细节: 分割线, 不可选中, 背景色, 正文的文字颜色和背景图片等(拉伸图片)
实现对自定义 Cell 中时间的判断, 如果与上一条消息发送时间相同, 则不显示
实现键盘弹出时整个 View 的随动效果(通知)
把键盘的 return 键变成 send 键, 实现新的消息的发送和回复(UITextField 的 delegate 方法)
1, 导入素材, 创建主界面
素材主要有一个的 plist 文件和相关的图片, plist 文件总体是一个数组, 每条消息是一个 Dictionary, 包含正文, 事件和类型条信息, 类型中 0 表示自己发送的消息, 1 表示别人发送的消息
plist 文件
2, 实现字典转模型和懒加载
由于要在懒加载时就计算控件的 Frame 和单元格行高, 所以创建两个 Model, 一个存储消息模型, 一个存储 Frame 信息
个人认为可以直接在一个 model 中存储 message 和 messageFrame 信息, 但是结构不够清晰, 维护相对复杂
由于消息的类型只有 0 和 1 两种类型, 为了更加直观, 可以将其存储为一个枚举形式
- typedef enum {
- LJMessageTypeMe = 0,// 表示自己发的消息
- LJMessageTypeOther = 1// 表示对方发的消息
- }LJMessageType;
- @implementation LJMessage
- -(instancetype)initWithDic:(NSDictionary *)dic
- {
- if (self = [super init]) {
- // 注意类成员的名称要与字典的键名称一致
- [self setValuesForKeysWithDictionary:dic];
- }
- return self;
- }
- +(instancetype)messageWithDic:(NSDictionary *)dic
- {
- return [[self alloc] initWithDic:dic];
- }
- @end
Frame 模型类中将 message 模型作为自己的成员之一, 还包括时间, 正文, 头像的 Frame, 以及单元格的行高..m 文件中需要重写 message 的 set 方法, 在这个方法中计算各控件的 Frame 和行高.
注意: 计算 Frame 需要先引入 < UIKit/UIKit.h>, 否则无法计算文字高度
懒加载的实质是重写模型对象的 get 方法, 当程序需要调用这个成员的 get 方法时, 在方法内部判断模型对象是否为空, 如果为空则加载对应的数据
懒加载通常的模式为: 1, 获取 plist 文件路径; 2, 根据文件路径创建一个由 NSDictionary 组成的 NSArray;3, 创建一个空的 NSMutableArray;4, 遍历每一个 NSDictionary, 使用模型对象的类方法或对象方法将其转换为模型对象; 5, 将该模型对象加入 NSMutableArray;6, 循环结束后将这个可变数组赋值给模型数据集;
注意: 因为后续要实现消息的发送和自动回复, 因此创建的模型数据集应该是一个可变数组
- -(NSMutableArray *)messageFrames
- {
- if (_messageFrames == nil) {
- NSString * path_plist = [[NSBundle mainBundle] pathForResource:@"messages.plist" ofType:nil];
- NSArray * messages_dic = [NSArray arrayWithContentsOfFile:path_plist];
- NSMutableArray * messageFrames_model = [NSMutableArray array];
- for (NSDictionary * dic in messages_dic) {
- LJMessageFrame * messageFrame_model_temp = [[LJMessageFrame alloc] init];
- LJMessage * currentMessage = [LJMessage messageWithDic:dic];
- messageFrame_model_temp.message= currentMessage;
- [messageFrames_model addObject:messageFrame_model_temp];
- }
- _messageFrames = messageFrames_model;
- }
- return _messageFrames;
- }
3, 创建自定义 Cell
创建一个基于 UITableViewCell 的类, 定义三个私有成员, lbl_time,img_icon,btn_text 分别表示时间, 头像和正文控件
重写自定义 Cell 的 initWithStyle: reuseIdentifier: 方法
- #define myFont [UIFont systemFontOfSize:18]
- -(instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier
- {
- if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
- // 时间
- UILabel * lbl_time = [[UILabel alloc] init];
- lbl_time.font = myFont;
- lbl_time.textAlignment = NSTextAlignmentCenter;
- [self.contentView addSubview:lbl_time];
- self.lbl_time = lbl_time;
- // 头像
- UIImageView * img_icon = [[UIImageView alloc] init];
- [self.contentView addSubview:img_icon];
- self.img_icon = img_icon;
- // 正文
- UIButton * btn_text = [[UIButton alloc] init];
- btn_text.titleLabel.font = myFont;
- btn_text.titleLabel.numberOfLines = 0;
- [self.contentView addSubview:btn_text];
- self.btn_text = btn_text;
- }
- return self;
- }
将整个自定义 Cell 的创建过程封装在一个类方法中, 方便用户调用
- +(instancetype)messageViewCellWithTableView:(UITableView *)tableView reuseidentifier:(NSString *)reuseID
- {
- LJMessageViewCell * cell = [tableView dequeueReusableCellWithIdentifier:reuseID];
- if (!cell) {
- cell = [[LJMessageViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:reuseID];
- }
- return cell;
- }
自定义 Cell 中的控件信息必须是根据其模型数据来决定的, 教程中的方法是为 Cell 添加一个 Frame 模型数据的成员变量, 通过重写 set 方法的方式来为各控件赋值. 这样比较容易理解, 使用也方便. 个人认为也可以为自定义 Cell 定义一个方法, 该方法使用 Frame 模型作为参数, 通过参数将模型数据信息传入, 然后为各控件赋值.
- @interface LJMessageViewCell : UITableViewCell
- @property(nonatomic, strong)LJMessageFrame * messageFrameModel;
- @property(nonatomic, strong)UILabel * lbl_time;
- @property(nonatomic, strong)UIImageView * img_icon;
- @property(nonatomic, strong)UIButton * btn_text;
- @end
- @implementation LJMessageViewCell
- -(void)setMessageFrameModel:(LJMessageFrame *)messageFrameModel
- {
- _messageFrameModel = messageFrameModel;
- // 时间内容
- self.lbl_time.text = messageFrameModel.message.time;
- self.lbl_time.frame = messageFrameModel.timeFrame;
- // 头像内容
- if (messageFrameModel.message.type == LJMessageTypeMe) {
- self.img_icon.image = [UIImage imageNamed:@"me"];
- }else{
- self.img_icon.image = [UIImage imageNamed:@"other"];
- }
- self.img_icon.frame = messageFrameModel.iconFrame;
- // 正文内容
- [self.btn_text setTitle:messageFrameModel.message.text forState:UIControlStateNormal];
- // 根据消息类型确定背景图名称和正文字体颜色
- NSString * nor,* highlight;
- if (messageFrameModel.message.type == LJMessageTypeMe) {
- nor = @"chat_send_nor";
- highlight = @"chat_send_press_pic";
- [self.btn_text setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
- } else {
- nor = @"chat_recive_nor";
- highlight = @"chat_recive_press_pic";
- [self.btn_text setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
- }
- UIImage * img_normal = [UIImage imageNamed:nor];
- UIImage * img_highlight = [UIImage imageNamed:highlight];
- // 设置背景图
- [self.btn_text setBackgroundImage:img_normal forState:UIControlStateNormal];
- [self.btn_text setBackgroundImage:img_highlight forState:UIControlStateHighlighted];
- // 设置 frame
- self.btn_text.frame = messageFrameModel.textFrame;
- }
- @end
4, 实现 tableView 的 datasource 方法
主要需要实现四个方法:
指定 tableView 有多少组
- -(NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
- {
- return 1;
- }
指定 tableView 每一组有多少行
- -(NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
- {
- return self.messageFrames.count;
- }
指定 tableView 某一组某一行的 Cell
- -(UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
- {
- LJMessageViewCell * cell = [LJMessageViewCell messageViewCellWithTableView:tableView reuseidentifier:@"message"];
- LJMessageFrame * messageframe_current = self.messageFrames[indexPath.row];
- cell.messageFrameModel = messageframe_current;
- return cell;
- }
指定 tableView 某一组某一行的行高
- -(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
- {
- LJMessageFrame * messageframe_current = self.messageFrames[indexPath.row];
- return messageframe_current.rowHeight;
- }
5, 计算各控件的 Frame
实现各控件的 frame 的计算, 主要的知识点在于对文字高度的计算, 对文本高度的计算方法有很多种, 教程中使用的是 boundingRectWithSize: 的方法:
该方法返回的是一个 CGRect 类型
第一个参数 size 传入一个 CGSize, 代表文本计算时的宽高方向的最大限制, 如果无限制则可以写 MAXFLOAT
第二个参数 options 传入一个计算方法, 它是一个枚举, 可以相互组合
- enum {
- // 如果文字内容超出指定的矩形限制, 文字将被截去并在最后一个字符后加上省略号
- NSStringDrawingTruncatesLastVisibleLine = 1 <<5,
- // 整个文本框将以每行组成的矩形为单位计算整个文字的尺寸
- NSStringDrawingUsesLineFragmentOrigin = 1 << 0,
- // 以字符的行距 (leading, 行距: 从一行文字的底部到另一行文字底部的间距) 来计算高度
- NSStringDrawingUsesFontLeading = 1 << 1,
- // 计算文本尺寸时将以每个字或字形为单位来计算
- NSStringDrawingUsesDeviceMetrics = 1 << 3,
- };typedef NSInteger NSStringDrawingOptions;
第三个参数 attributes 传入应用于 NSString 字符串的文本属性对应的字典
第四个参数 Context 用于控制如何调整字符的间距和缩放, 可以为 nil
- #import <UIKit/UIKit.h>
- #define myFont [UIFont systemFontOfSize:18]
- -(void)setMessage:(LJMessage *)message
- {
- _message = message;
- // 获取屏幕宽度
- CGFloat screenW = [UIScreen mainScreen].bounds.size.width;
- static CGFloat margin = 5;
- NSDictionary * attr = @{NSFontAttributeName:myFont};
- NSString * text_string = message.text;
- CGSize text_size = [text_string boundingRectWithSize:CGSizeMake(200.0, MAXFLOAT) options:NSStringDrawingUsesLineFragmentOrigin attributes:attr context:nil].size;
- CGFloat textX = 0;
- CGFloat textY = iconY;
- CGFloat textW = text_size.width;
- CGFloat textH = text_size.height;
- if (message.type == LJMessageTypeOther) {
- textX = CGRectGetMaxX(self.iconFrame);
- } else {
- textX = iconX - textW;
- }
- self.textFrame = CGRectMake(textX, textY, textW, textH);
- }
注意: 计算 Frame 使用的文本属性应与实际显示的文本属性相同, 否则会出现错误, 另外文本框的 numberOfLines 应设置为 0, 否则无法换行.
6, 修正 tableView 的细节
该部分主要包括: 取消分割线, 使单元格不可选中, 设定 tableview 和 cell 的背景色, 设定当拖动 tableView 时收回键盘, 根据消息类型设置正文颜色和背景图. 主要的知识点在于背景图的拉伸方式的设定, 教程中使用的方法是
- (UIImage *)stretchableImageWithLeftCapWidth:(NSInteger)leftCapWidth topCapHeight:(NSInteger)topCapHeight;
该方法是通过设置左边不拉伸区域的宽度和上面不拉伸区域的高度来达到边角不拉伸的效果
leftCapWidth: 左边不拉伸区域的宽度
topCapWidth: 上面不拉伸区域的高度
扩展:
rightCapWidth = 图片宽度 - leftCapWidth -1
bottomCapWidth = 图片高度 - topCapWidth -1
而拉伸区域 capInset 实际上是 (topCapHeight,leftCapWidth,bottomCapWidth,rightCapWidth) 所以一般 leftCapWidth 取图片宽度的一般, topCapHeight 取图片高度的一般, 来获取拉伸区域 1*1 的矩阵来复制填充(UIImageResizingModeTile), 保持外围的区域不变
与该方法类似的还有一个是 resizableImageWithCapInsets
- (UIImage *)resizableImageWithCapInsets:(UIEdgeInsets)capInsets resizingMode:(UIImageResizingMode)resizingMode NS_AVAILABLE_IOS(6_0);
7, 实现对自定义 Cell 中时间的判断
判断时间是否需要显示的关键在于判断当前消息的时间与前一条消息的事件是否相同, 思路是在懒加载的 for 循环里, 将当前的消息模型与上一条消息模型进行对比, 上一条消息模型可以在当前模型尚未加入到 NSMutableArray 之前通过 lastObject 获取. 然后在 Frame 模型中定义一个 Bool 成员变量, 用于记录当前消息是否应该显示时间.
如果不需要显示时间, 则可以直接不计算时间控件的 frame
8, 实现键盘弹出时整个 View 的随动效果
该效果实现的难点主要在于如何知道键盘的尺寸发生了改变, 并且获取键盘改变之后的尺寸, 教程中使用了通知机制
通知机制
每一个应用程序都有一个通知中心 (NSNotificationCenter) 实例, 专门负责协调不同对象之间的消息通信; 任何一个对象都可以向通知中心发布通知 (NSNotification), 描述自己在做什么. 其它感兴趣的对象(Observer) 可以申请在某个特定通知发布时 (或在某个特定的对象发布通知时) 收到这个通知.
通知 NSNotification
属性
- - (NSString *)name; // 通知的名称
- - (id)object; // 通知发布者(是谁要发布通知)
- - (NSDictionary *)userInfo; // 一些额外的信息(通知发布者传递给通知接受者的信息内容)
初始化
- + (instancetype)notificationWithName:(NSString *)aName object:(id)anObject;
- + (instancetype)notificationWithName:(NSString *)aName object:(id)anObject userInfo:(NSDictionary *)aUserInfo;
- + (instancetype)notificationWithName:(NSString *)aName object:(id)anObject userInfo:(NSDictionary *)aUserInfo;
通知中心 NSNotificationCenter
发布通知
- (void)postNotification:(NSNotification *)notification;
发布一个 notification 通知, 可在 notification 对象中设置通知的名称, 通知发布者, 额外信息等
- (void)postNotification:(NSNotification *)notification;
发布一个名称为 aName 的通知, anObject 为这个通知的发布者
- (void)postNotificationName:(NSString *)aName object:(id)anObject userInfo:(NSDictionary *)aUserInfo;
发布一个名称为 aName 的通知, anObject 为这个通知的发布者, aUserInfo 为额外信息
注册通知监听器
- (void)addObserver:(id)observer selector:(SEL)aSelector name:(NSString *)aName object:(id)anObject;
observer: 监听器, 即谁要接收这个通知;
aSelector: 收到通知后, 回调监听器的这个方法, 并且把通知对象当做参数传入
aName: 通知的名称. 如果为 nil, 那么无论通知的名称是什么, 监听器都能收到这个通知
anObject: 通知发布者. 如果为 nil, 那么无论谁发布的这个名称的通知, 监听器都能收到这个通知
(id)addObserverForName:(NSString *)name object:(id)obj queue:(NSOperationQueue *)queue usingBlock:(void (^)(NSNotification *note))block;
name: 通知的名称
obj: 通知发布者
block: 收到对应的通知时, 会回调这个 block
queue: 决定了 block 在哪个操作队列中执行, 如果传 nil, 默认在当前操作队列中同步执行
取消注册通知监听器
通知中心不会保留 (retain) 监听器对象, 在通知中心注册过的对象, 必须在该对象释放前取消注册. 否则, 当对应的通知再次出现时, 通知中心仍然会向该监听器发送消息. 因为相应的监听器对象已经被释放了, 所以可能会导致应用崩溃.
- - (void)removeObserver:(id)observer;
- - (void)removeObserver:(id)observer name:(NSString *)aName object:(id)anObject;
键盘相关的通知
键盘状态改变的时候, 系统会发出一些特定的通知, 我们不需要关心这些通知的发送者是谁, 只要知道通知的名称即可
- UIKeyboardWillShowNotification // 键盘即将显示
- UIKeyboardDidshowNotification // 键盘显示完毕
- UIKeyboardWillHideNotification // 键盘即将隐藏
- UIKeyboardDidHideNotification // 键盘隐藏完毕
- UIKeyboardWillChangeFrameNotification // 键盘的位置尺寸即将发生改变
- UIKeyboardDidChangeFrameNotification // 键盘的位置尺寸改变完毕
键盘通知发出时, 通知 NSNotification 中会附带跟键盘有关的额外信息[字典], 字典中常见的 key 如下:
- UIKeyboardFrameBeginUserInfoKey // 键盘刚开始的 frame
- UIKeyboardFrameEndUserInfoKey // 键盘最终的 frame(动画执行完毕后)
- UIKeyboardAnimationDurationUserInfoKey // 键盘动画的时间
- UIKeyboardAnimationCurveUserInfoKye // 键盘动画的执行节奏(快慢)
具体实现代码如下:
- -(void)ViewChangeWithKeyboard:(NSNotification *) NoteInfo
- {
- // 获取通知内容中键盘的高度信息
- CGRect endSize = [NoteInfo.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
- CGFloat endSize_Y = endSize.origin.y;
- // 获取通知内容中键盘的移动时间信息
- NSTimeInterval time = [NoteInfo.userInfo[UIKeyboardAnimationDurationUserInfoKey] floatValue];
- // 获取当前屏幕的尺寸并计算整个 view 应移动到的 Y 值
- CGFloat screenH = [UIScreen mainScreen].bounds.size.height;
- CGFloat screenW = [UIScreen mainScreen].bounds.size.width;
- CGFloat moveToY = endSize_Y - screenH;
- // 动画效果实现 view 的移动
- [UIView animateWithDuration:time animations:^{
- self.view.frame = CGRectMake(0, moveToY, screenW, screenH);
- }];
- }
9, 实现新的消息的发送和回复
实现新消息的发送和回复主要的关键点在于监听文本框的输入操作, 并更新 tableView 的数据源. 实现思路如下:
使用 UItextField 的 delegate 方法 textFieldShouldReturn: 来监听发送按钮的点击操作
获取当前文本框的内容
获取当前时间并转换为指定的格式
根据消息正文和消息类型创建消息模型和 Frame 模型
将新的 Frame 模型加入到模型数据集和中
刷新 tableView 并将最后一行滚动到第一行
清空文本框
如果需要实现自动回复, 将发送消息的功能进行封装即可, 通过传入一个消息正文和消息类型实现自动发送
- -(void)sendMessage:(NSString *)message WithType:(LJMessageType)type
- {
- // 获取当前时间
- NSDate * nowdate = [NSDate date];
- NSDateFormatter * dateFormatter = [[NSDateFormatter alloc] init];
- [dateFormatter setDateFormat:@"今天 HH:mm"];
- NSString * nowTime = [dateFormatter stringFromDate:nowdate];
- // 根据文本框的内容创建 LJMessageFrame 对象
- LJMessage * message_send_model = [[LJMessage alloc] init];
- message_send_model.text = message;
- message_send_model.time = nowTime;
- message_send_model.type = type;
- LJMessageFrame * messageFrame_model = [[LJMessageFrame alloc] init];
- messageFrame_model.message = message_send_model;
- // 将创建的对象加入到模型数据集合中
- [self.messageFrames addObject:messageFrame_model];
- // 刷新 tableView
- [self.messageView reloadData];
- }
以上就是 QQ 聊天 UI 界面的案例总结, 有不足之处欢迎留言, 一起学习一起进步!
来源: http://www.jianshu.com/p/beb5dbe0d780