仿制QQ
| 前言 |
最近結束了一個階段的學習,有點空空蕩蕩的感覺。為了減少心中的“寂寞空虛冷”,加上沒有什么實際的項目來練手,便想到做一些小Demo來自我驗收一下學習成果,Demo的功能就仿制于各大主流App。至于如何仿制、與原版的區別、UI設計的重要性,且看下文詳情分解! :)
| 迷惘總有時 |
最初是看到酷狗音樂的一些界面效果很有意思,就是這個:
點擊右邊的按鈕會動態插入一個視圖,正如這樣:
就想著自己也來搗鼓搗鼓,看能不能實現出這樣的功能!
實踐1
當點擊按鈕時,動態插入一個視圖。循著這個思路,瀏覽了一遍UITableView的方法,下面的方法可能是我想要的:
- (void)beginUpdates; // allow multiple insert/delete of rows and sections to be animated simultaneously. - (void)endUpdates; // only call insert/delete/reload calls or change the editing state inside an update block. otherwise things like row count, etc. may be invalid.- (void)insertSections:(NSIndexSet *)sections withRowAnimation:(UITableViewRowAnimation)animation; - (void)insertRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths withRowAnimation:(UITableViewRowAnimation)animation;- (void)deleteSections:(NSIndexSet *)sections withRowAnimation:(UITableViewRowAnimation)animation; - (void)deleteRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths withRowAnimation:- (void)moveSection:(NSInteger)section toSection:(NSInteger)newSection NS_AVAILABLE_IOS(5_0); - (void)moveRowAtIndexPath:(NSIndexPath *)indexPath toIndexPath:(NSIndexPath *)newIndexPath NS_AVAILABLE_IOS(5_0);上述方法在表視圖的編輯模式下經常被使用到,但是它們僅能在已有的視圖中增加、刪除、移動。例如QQ的分組管理視圖中,移動分組和刪除分組是在原有的視圖基礎上進行操作。在添加分組時,通過模態視圖輸入組名,然后刷新表視圖的數據源,達到新增分組的目的。顯然,此處場景不能應用此方法。
實踐2
在實踐1上一番折騰無果后,就向學長請假了這個問題,他給出了他的解決方法——最開始將每個分組的cell數量設為0,當點擊每個分組的headerView時,刷新數據并將cell正常設置為數據源所提供的數量。看了他給出的Demo,發現也正是基于這個巧妙的設計,在最開始進入QQ好友管理列表視圖時所有的分組就都是“關閉”的。同時在酷狗音樂中,你的“已下載(XXX)”是無法像“正在下載(XXX)”“折疊”成一個組的。鑒于道理的相通性,筆者在恍然大悟之后就轉身投入到仿制QQ的懷抱中了。
| 水是由水分子組成的 |
為了構建這個界面:
我們需要一些假數據,包括分組信息、好友信息等。為此筆者設計了groups.plist這個文件,它長成這樣:
當然,還得有一些圖片資源充當好友頭像之類的。
接下來,我們就需要從假數據的文件中讀取數據并且創建數據實體。根據數據之間的相互依賴關系,筆者構建了DataSource、Friend、FriendsGroups,下面是它們的一些關鍵代碼:
//DataSource.h + (NSArray *)dataStore;//DataSource.m + (NSArray *)dataStore {NSArray *groupsData = [NSArray arrayWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"groups" ofType:@"plist"]];return groupsData; }//FriendsGroups.h @property (nonatomic, strong, readonly) NSArray *friends;@property (nonatomic, copy, readonly) NSString *groupName;@property (nonatomic, assign, readonly) NSNumber *totalPersonCount; @property (nonatomic, assign, readonly) NSNumber *onlinePersonCount;@property (nonatomic, assign, getter=isOpened) BOOL open;- (instancetype)initWithGroupsMsg:(NSDictionary *)msg;//FriendsGroups.m @synthesize friends = _friends; // because of readonly#pragma mark - Initializer- (instancetype)initWithGroupsMsg:(NSDictionary *)msg {self = [super init];if (self) {[self setValuesForKeysWithDictionary:msg]; // KVCNSMutableArray *friends = [NSMutableArray array];for (NSDictionary *friendMsg in self.friends) {Friend *friend = [[Friend alloc] initWithFriendMsg:friendMsg];[friends addObject:friend];}_friends = friends;}return self; }//Friend.h @property (nonatomic, copy, readonly) NSString *iconName; @property (nonatomic, copy, readonly) NSString *nickName; @property (nonatomic, copy, readonly) NSString *intro;@property (nonatomic, strong, readonly) NSArray *chatLog;@property (nonatomic, assign, readonly, getter=isVip) BOOL vip; @property (nonatomic, assign, readonly, getter=isOnline) BOOL online;- (instancetype)initWithFriendMsg:(NSDictionary *)msg;//Friend.m - (instancetype)initWithFriendMsg:(NSDictionary *)msg {self = [super init];if (self) {[self setValuesForKeysWithDictionary:msg]; // KVC}return self; }數據配合與界面的交互,最終的功能就能慢慢的成型了!
| MVVM實踐 |
正如被誤解的MVC和被神化的MVVM一文所說的那樣,MVC的問題在于臃腫的控制器,而將那些在控制器中處理的邏輯抽出來,形成一個新的模塊:ViewModel,就可以完成MVC到MVVM的過渡。在這個場景中,筆者就構建了一個ListViewModel來處理一些原本在控制器里完成的邏輯(ps:可能有些“不干不凈”,經驗欠缺,獻丑了!),它里面有這樣一些代碼:
//ListViewModel.h @property (nonatomic, strong, readonly) NSArray<FriendsGroups *> *groups; @property (nonatomic, strong, readonly) NSArray<NSNumber *> *groupsStatus; @property (nonatomic, strong, readonly) NSArray<NSNumber *> *aGroupFriendCounts;@property (nonatomic, assign, readonly) NSInteger groupsCount;//ListViewModel.m @synthesize groups = _groups; @synthesize groupsCount = _groupsCount; @synthesize groupsStatus = _groupsStatus; @synthesize aGroupFriendCounts = _aGroupFriendCounts;#pragma mark - getters- (NSArray<FriendsGroups *> *)groups {if (!_groups) { // lazy loadNSArray *groupsInfo = [DataStore dataStore];NSMutableArray *msgGroups = [NSMutableArray array];for (NSDictionary *groupsMsg in groupsInfo) {FriendsGroups *group = [[FriendsGroups alloc] initWithGroupsMsg:groupsMsg];[msgGroups addObject:group];}_groups = msgGroups;}return _groups; }- (NSInteger)groupsCount {if (!_groupsCount) {_groupsCount = self.groups.count;}return _groupsCount; }- (NSArray<NSNumber *> *)groupsStatus { // if (!_groupsStatus) { // when the status of group is changed, groupsStatus should updateNSMutableArray *statusInfo = [NSMutableArray array];for (FriendsGroups *group in self.groups) {[statusInfo addObject:@(group.isOpened)];}_groupsStatus = statusInfo; // }return _groupsStatus; }- (NSArray<NSNumber *> *)aGroupFriendCounts {if (!_aGroupFriendCounts) {NSMutableArray *friendCounts = [NSMutableArray array];for (FriendsGroups *group in self.groups) {[friendCounts addObject:@(group.friends.count)];}_aGroupFriendCounts = friendCounts;}return _aGroupFriendCounts; }| 你用我的,我用你的 |
有了上面一系列的準備工作,視圖控制器就可以瘦身了。“媽媽再也不用擔心我被叫做重度視圖控制器了”,某VC如是說。
//CustomTableViewController.m #pragma mark - Table View DataSource - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {return self.viewModel.groupsCount; }- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {return [self.viewModel.groupsStatus[section] boolValue] ? [self.viewModel.aGroupFriendCounts[section] integerValue]: 0; }- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {FriendCell *cell = [tableView dequeueReusableCellWithIdentifier:[FriendCell reuseID] forIndexPath:indexPath];cell.cellModel = self.viewModel.groups[indexPath.section].friends[indexPath.row];cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;cell.layer.shouldRasterize = YES;cell.layer.rasterizationScale = [UIScreen mainScreen].scale;return cell; }#pragma mark - Table View Delegate- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section {CustomHeaderView *headerView = [tableView dequeueReusableHeaderFooterViewWithIdentifier:[CustomHeaderView reuseID]];headerView.delegate = self;headerView.group = self.viewModel.groups[section];headerView.sectionIndex = section;// cache layer to improve preformanceheaderView.layer.shouldRasterize = YES;headerView.layer.rasterizationScale = [UIScreen mainScreen].scale;return headerView; }為了能在點擊headerView,折疊或展開分組,我們需要監聽一下它的點擊,然后局部刷新視圖即可。嗯,代理模式是個不錯的選擇,說干就干:
//CustomHeaderView.h @protocol foldViewDelegate <NSObject>@required- (void)tableViewDidFoldView:(NSInteger)section;@end@interface CustomHeaderView : UITableViewHeaderFooterView@property (nonatomic, strong) FriendsGroups *group;@property (nonatomic, assign) NSInteger sectionIndex;@property (nonatomic, weak) id<foldViewDelegate> delegate;+ (NSString *)reuseID;//CustomHeaderView.m static NSString *const headerViewID = @"headerCELL";@interface CustomHeaderView()@property (nonatomic, strong) UIImageView *listIcon; @property (nonatomic, strong) UIView *lineView;@property (nonatomic, strong) UILabel *groupNameLabel; @property (nonatomic, strong) UILabel *personCountLabel;@property (nonatomic, strong) UIButton *viewTouched;@end@implementation CustomHeaderView#pragma mark - Initializer- (instancetype)initWithReuseIdentifier:(NSString *)reuseIdentifier {self = [super initWithReuseIdentifier:reuseIdentifier];if (self) {[self.contentView addSubview:self.listIcon];[self.contentView addSubview:self.groupNameLabel];[self.contentView addSubview:self.personCountLabel];[self.contentView addSubview:self.lineView];[self.contentView addSubview:self.viewTouched];}return self; }#pragma mark - Layout Subviews- (void)layoutSubviews {[super layoutSubviews];[self.listIcon mas_makeConstraints:^(MASConstraintMaker *make) {make.centerY.equalTo(self);make.leading.mas_equalTo(10);make.size.mas_equalTo(CGSizeMake(7, 11));}];[self.groupNameLabel mas_makeConstraints:^(MASConstraintMaker *make) {make.leadingMargin.equalTo(self.listIcon).mas_equalTo(22);make.centerY.equalTo(self.listIcon);make.top.equalTo(self);make.bottom.equalTo(self);make.width.mas_equalTo(187);}];[self.personCountLabel mas_makeConstraints:^(MASConstraintMaker *make) {make.trailingMargin.equalTo(self).mas_equalTo(0);make.top.equalTo(self);make.bottom.equalTo(self);//make.width.mas_equalTo(50);}];[self.lineView mas_makeConstraints:^(MASConstraintMaker *make) {make.leading.equalTo(self);make.trailing.equalTo(self);make.bottom.equalTo(self);make.height.mas_equalTo(1);}];[self.viewTouched mas_makeConstraints:^(MASConstraintMaker *make) {make.edges.equalTo(self);}]; }#pragma mark - Interface Method+ (NSString *)reuseID {return headerViewID; }#pragma mark - Private Method- (void)handleTouched:(UIButton *)sender {self.group.open = !self.group.isOpened;if ([_delegate respondsToSelector:@selector(tableViewDidFoldView:)]) {[_delegate tableViewDidFoldView:self.sectionIndex];} }#pragma mark - getters and setters- (UIImageView *)listIcon {if (!_listIcon) {_listIcon = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"listIcon"]];_listIcon.contentMode = UIViewContentModeScaleAspectFit;}return _listIcon; }- (UILabel *)groupNameLabel {if (!_groupNameLabel) {_groupNameLabel = [[UILabel alloc] init];_groupNameLabel.text = @"我是組名怎么滴";}return _groupNameLabel; }- (UILabel *)personCountLabel {if (!_personCountLabel) {_personCountLabel = [[UILabel alloc] init];_personCountLabel.text = @"12/12";_personCountLabel.textColor = [UIColor grayColor];_personCountLabel.textAlignment = NSTextAlignmentCenter;}return _personCountLabel; }- (UIView *)lineView {if (!_lineView) {_lineView = [[UIView alloc] init];_lineView.backgroundColor = [UIColor grayColor];}return _lineView; }- (UIButton *)viewTouched {if (!_viewTouched) {_viewTouched = [UIButton buttonWithType:UIButtonTypeSystem];_viewTouched.backgroundColor = [UIColor clearColor];[_viewTouched addTarget:self action:@selector(handleTouched:) forControlEvents:UIControlEventTouchUpInside];}return _viewTouched; }- (void)setGroup:(FriendsGroups *)group {if (_group != group) {_group = group;self.groupNameLabel.text = _group.groupName;self.personCountLabel.text = [NSString stringWithFormat:@"%@/%@", _group.onlinePersonCount, _group.totalPersonCount];}CGFloat angle = self.group.open ? M_PI_2 : 0;self.listIcon.transform = CGAffineTransformMakeRotation(angle); }@end然后讓視圖控制器遵循折疊視圖協議,并實現相應方法:
//CustomTableViewController.m @interface CustomTableViewController () <foldViewDelegate>......#pragma mark - Fold View Delegate- (void)tableViewDidFoldView:(NSInteger)section {[self.tableView reloadSections:[NSIndexSet indexSetWithIndex:section] withRowAnimation:UITableViewRowAnimationAutomatic]; }至此,好友列表功能就大體實現了,效果是這樣的:
| 常聯系,不冷漠 |
筆者在假數據文件中,有一個數據項叫做chatLog,用于記錄該好友給你發的消息。當你點擊某好友的cell時,視圖隨之過渡到消息界面,希望你看到那或長或短的簡易文字,總能體會到人情的溫暖。(ps:煽情結束,^_^)
毫無疑問,消息界面的cell的高度是隨著消息的長度而改變的,所以就需要動態的調整它的高度。好消息是自iOS8后,系統就支持動態調節高度了。對筆者來說是壞消息的是在這個Demo工程中就一直實現不了,曾一度懷疑是不是Xcode8的Bug,然而今天新建了個測試工程就又行了。哎,手動計算加上選取背景圖片的不當致使文字嵌套在氣泡中的效果沒有達到預想中的效果,不過當做演示還算夠用!為了給視圖控制器減負,筆者再次把玩了下MVVM,下面貼出根據文字及其大小計算它所需要占用的尺寸的代碼:
//MessageViewModel.m ......NSAttributedString *attriMsg = [[NSAttributedString alloc] initWithString:message attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:14.0f]}];CGRect msgSize = [attriMsg boundingRectWithSize:CGSizeMake([UIScreen mainScreen].bounds.size.width - 136.0f, 2000.0f) options:NSStringDrawingUsesLineFragmentOrigin context:nil];......當要通過表視圖的代理方法返回cell的高度時,就從存儲高度的字典中取出即可,這樣就免去了控制器計算的負擔。完成后的效果是這樣的:
灰色的區域就是按鈕的真實區域。可明顯看出因為氣泡背景左邊的一個尖角對按鈕的標題的嵌入不怎么搭配,而且背景也不能完全適應整個按鈕的大小,即使控制標題的偏移,這個問題也不能很好的解決。而真實的QQ中使用的氣泡是這樣的:
那些主題氣泡背景也是這種方方正正的形狀,尖角被放置在最頂部估計也是QQ的UI設計團隊基于這方面的考量。所以說UI設計是應用的靈魂所在,這也就難怪一些UI設計師一張小小的圖片就敢收費幾百上千了!
| 總結 |
不知不覺,文章的篇幅已經這么長了,主要原因估計就是充斥著太多的代碼,文字的串接其實比較少。這么做的原因有兩點:一是方便看這篇文章的朋友理解Demo的實現,二是筆者文辭有點匱乏,寫博客的技巧尚待提高。
雖然時斷時續,不過總算寫完了這個類別下的第一篇文章。筆者后面會繼續嘗試仿制其他的一些功能,一來記錄下學習的點滴,二來分享在這過程中用到的一些東西。完整的Demo可以在這里找到,謝謝!
總結
- 上一篇: CCSP Official (ISC)2
- 下一篇: 高斯函数和C++简单实现