iOS 玩转微信——通讯录
概述
-
自
2019年初--至今
,笔者为求生计,被迫转学Vue
开发,老兵不死,只会逐渐凋零,以致于渐渐冷落了iOS开发
,毕竟有舍便有得
,不逼自己一把,也不知道自己有多优秀。 -
由于大家对 WeChat 中运用的
MVVM + RAC + ViewModel-Based Navigation
的模式比较感兴趣,但是此前此项目主要是用于团队内部交流使用,主要介绍了其中使用技巧和实用技术,以及一些细节处理,实用为主,功能为辅。 -
尽管实现了微信的整体架构,以及朋友圈等功能,但是其中还是充斥着不少测试代码,这让整体项目看起来像个
Demo
,并且不够优美,随着微信 7.0.0+
的出现,整体UI也发生了翻天覆地的变化,所以,只好痛定思痛,重蹈覆辙,重拾iOS,这里先以高仿微信通讯录
为例,宣告笔者强势复出,后期争取尽自己最大努力,98%还原真实微信开发,不断剖析其中的技术实现和细节处理。 -
笔者希望通过学习和实践这个项目,也能够打开学习
ReactiveCocoa + MVVM
的大门。当然同时也是抛砖引玉,摆渡众生、取长补短,希望能够提供一点思路,少走一些弯路,填补一些细坑,在帮助他人的过程中,收获分享技术的乐趣。 -
源码地址:WeChat
预览
索引 |
侧滑 |
---|---|
ios_contacts_page_0.png | ios_contacts_page_1.png |
GIF
功能
通讯录模块,尽管UI看起来极其简单,但是涵盖不少知识点,也是通讯录模块的功能所在,本篇文章将详述以下知识点以及实现的细节:
-
汉字转拼音
、数据排序
、按字母分组
底部上拉显示白底
-
A-Z 索引Bar
、索引联动
、悬停HeaderView渐变
-
Cell 侧滑备注
,修改侧滑样式
分析
数据处理
首先,主要是将联系人姓名转成拼音,然后取联系人拼音首字母;其次,利用字典(NSDictionary
)的key
的唯一性,将联系人的首字母插入到字典当中去;最后,取出字典的allKeys
进行字母排序,然后遍历数据,进行按字母分组。
这里的核心技术就是汉字转拼音
,当然大家可以使用iOS原生库方法
和 PinYin4Objc来实现,这里笔者主要讲讲,iOS原生提供的API:
/// string 要转换的string,比如要转换的中文,同时它是mutable的,因此也直接作为最终转换后的字符串。
/// range是要转换的范围,同时输出转换后改变的范围,如果为NULL,视为全部转换。
/// transform可以指定要进行什么样的转换,这里可以指定多种语言的拼写转换。
/// reverse指定该转换是否必须是可逆向转换的。
/// 如果转换成功就返回true,否则返回false
Boolean CFStringTransform(CFMutableStringRef string, CFRange *range, CFStringRef transform, Boolean reverse);
CFMutableStringRef string = CFStringCreateMutableCopy(NULL, 0, CFSTR("芈月"));
CFStringTransform(string, NULL, kCFStringTransformMandarinLatin, NO);
NSLog(@"%@",string);
/// 打印结果:mǐ yuè
/// 由于👆正确的输出了拼音,而且还带上了音标。有时候我们不需要音标怎么办?还好CFStringTransform同时提供了将音标字母转换为普通字母的方法kCFStringTransformStripDiacritics。我们在上面的代码基础上再加上这个:
CFStringTransform(string, NULL, kCFStringTransformStripDiacritics, NO);
NSLog(@"%@",string);
/// 打印结果:mi yue
由于后期考虑到,搜索模块需要增加本地搜索联系人的需求,所以本项目这里采用了内部已经封装好 PinYin4Objc的HighlightedSearch,它支持搜索关键字,高亮显示,支持汉字、全拼、简拼搜索,支持多音字搜索。
汉子转拼音API如下:
/// WPFPinYinTools.h
/** 获取传入字符串的第一个拼音字母 */
+ (NSString *)firstCharactor:(NSString *)aString withFormat:(HanyuPinyinOutputFormat *)pinyinFormat;
数据处理整体代码如下:
/// 联系人数据处理
- (void)_handleContacts:(NSArray *)contacts {
if (MHObjectIsNil(contacts) || contacts.count == 0) return;
// 计算总人数
self.total = [NSString stringWithFormat:@"%ld位联系人",contacts.count];
// 这里需要处理数据
NSMutableDictionary *tempDict = [[NSMutableDictionary alloc] init];
// 获取首字母
for(MHUser *contact in contacts){
// 存到字典中去 <ps: 由于 contacts.json 的wechatId 都是拼音 so...>
[tempDict setObject:contact forKey:[[contact.wechatId substringToIndex:1] uppercaseString]];
}
//排序,排序的根据是字母
NSComparator comparator = ^(id obj1, id obj2) {
if ([obj1 characterAtIndex:0] > [obj2 characterAtIndex:0]) {
return (NSComparisonResult)NSOrderedDescending;
}
if ([obj1 characterAtIndex:0] < [obj2 characterAtIndex:0]) {
return (NSComparisonResult)NSOrderedAscending;
}
return (NSComparisonResult)NSOrderedSame;
};
// 已经排好序的数据
NSMutableArray *letters = [tempDict.allKeys sortedArrayUsingComparator: comparator].mutableCopy;
NSMutableArray *viewModels = [NSMutableArray array];
/// 遍历数据
for (NSString *letter in letters) {
// 存储相同首字母 对象
NSMutableArray *temps = [[NSMutableArray alloc] init];
// 存到数组中去
for (NSInteger i = 0; i<contacts.count; i++) {
MHUser *contact = contacts[i];
if ([letter isEqualToString:[[contact.wechatId substringToIndex:1] uppercaseString]]) {
MHContactsItemViewModel *viewModel = [[MHContactsItemViewModel alloc] initWithContact:contact];
[temps addObject:viewModel];
}
}
[viewModels addObject:temps];
}
/// 需要配置 新的朋友、群聊、标签、公众号、
MHContactsItemViewModel *friends = [[MHContactsItemViewModel alloc] initWithIcon:@"plugins_FriendNotify_36x36" name:@"新的朋友"];
MHContactsItemViewModel *groups = [[MHContactsItemViewModel alloc] initWithIcon:@"add_friend_icon_addgroup_36x36" name:@"群聊"];
MHContactsItemViewModel *tags = [[MHContactsItemViewModel alloc] initWithIcon:@"Contact_icon_ContactTag_36x36" name:@"标签"];
MHContactsItemViewModel *officals = [[MHContactsItemViewModel alloc] initWithIcon:@"add_friend_icon_offical_36x36" name:@"公众号"];
// 插入到第一个位置
[viewModels insertObject:@[friends,groups,tags,officals] atIndex:0];
// 插入一个
[letters insertObject:UITableViewIndexSearch atIndex:0];
self.dataSource = viewModels.copy;
self.letters = letters.copy;
}
页面展示
当数据处理完,构建好cell
,刷新tableView
,理论上页面展示和微信页面应该一模一样👍。当然我们滚动到页面的最底部,继续上拉,会露出tableView
的浅灰色(#ededed)
的背景色,但是看看微信的上拉,露出的却是白色
的背景色,所以必须把这个细节加上去。
实现逻辑非常简单,只需要设置tableViiew
的背景色为透明色
,然后添加一个白色
背景的UIView
在tableView
的下面即可,默认隐藏,等有数据时才去显示。实现代码如下:
/// 添加一个tempView 放在最底下 用于上拉显示白底
UIView *tempView = [[UIView alloc] init];
self.tempView = tempView;
// 默认隐藏
tempView.hidden = YES;
tempView.backgroundColor = [UIColor whiteColor];
[self.view insertSubview:tempView belowSubview:self.tableView];
[self.tempView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.view).with.offset(0);
make.right.equalTo(self.view).with.offset(0);
make.bottom.equalTo(self.view).with.offset(0);
make.height.mas_equalTo(MH_SCREEN_HEIGHT * 0.5);
}];
Cell侧滑备注
功能实现,笔者这里采用iOS 11.0
提供的左滑删除功能,只需实现UITableViewDelegate
即可。
- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath {
if (indexPath.section == 0) {
return NO;
}
return YES;
}
- (nullable UISwipeActionsConfiguration *)tableView:(UITableView *)tableView trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath API_AVAILABLE(ios(11.0)){
UIContextualAction *remarkAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleNormal title:@"备注" handler:^(UIContextualAction * _Nonnull action, __kindof UIView * _Nonnull sourceView, void (^ _Nonnull completionHandler)(BOOL)) {
completionHandler(YES);
}];
UISwipeActionsConfiguration *config = [UISwipeActionsConfiguration configurationWithActions:@[remarkAction]];
config.performsFirstActionWithFullSwipe = NO;
return config;
}
由于最新微信侧滑备注
是浅黑色(#4c4c4c)
,而系统默认的则是浅灰色
的,所以我们需要修改系统的样式,由于每次侧滑,都有调用- (void)tableView:(UITableView *)tableView willBeginEditingRowAtIndexPath:(NSIndexPath *)indexPath
这个API,而且我们利用Debug view Hierarchy
工具查看层级,发现侧滑是加在tableView
上,而不是cell
上,所以解决思路如下:一旦调用此API,立即遍历tableView
的subView
,然后找到对应的UISwipeActionPullView
,修改其内部的UISwipeActionStandardButton
背景色。
但是这里需要指出的是,由于存在两种层级关系如下:
-
iOS 13.0+:
UITableView --> _UITableViewCellSwipeContainerView --> UISwipeActionPullView --> UISwipeActionStandardButton
-
iOS 13.0-:
UITableView --> UISwipeActionPullView --> UISwipeActionStandardButton
所以最终处理如下:
- (void)tableView:(UITableView *)tableView willBeginEditingRowAtIndexPath:(NSIndexPath *)indexPath {
/// 注意低版本的Xcode中 不一定是 `_UITableViewCellSwipeContainerView+UISwipeActionPullView+UISwipeActionStandardButton` 而是 `UISwipeActionPullView+UISwipeActionStandardButton`
for (UIView *subView in tableView.subviews) {
if ([subView isKindOfClass:NSClassFromString(@"UISwipeActionPullView")]) {
subView.backgroundColor = MHColorFromHexString(@"#4c4c4c");
for (UIButton *button in subView.subviews) {
if ([button isKindOfClass:NSClassFromString(@"UISwipeActionStandardButton")]) {
// 修改背景色
button.backgroundColor = MHColorFromHexString(@"#4c4c4c");
}
}
} else if ([subView isKindOfClass:NSClassFromString(@"_UITableViewCellSwipeContainerView")]) {
for (UIView *childView in subView.subviews) {
if ([childView isKindOfClass:NSClassFromString(@"UISwipeActionPullView")]) {
childView.backgroundColor = MHColorFromHexString(@"#4c4c4c");
for (UIButton *button in childView.subviews) {
if ([button isKindOfClass:NSClassFromString(@"UISwipeActionStandardButton")]) {
// 修改背景色
button.backgroundColor = MHColorFromHexString(@"#4c4c4c");
}
}
}
}
}
}
}
当然点击备注
时,也得修改其背景色,否则又会被重置为浅灰色
,代码如下:
- (nullable UISwipeActionsConfiguration *)tableView:(UITableView *)tableView trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath API_AVAILABLE(ios(11.0)){
UIContextualAction *remarkAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleNormal title:@"备注" handler:^(UIContextualAction * _Nonnull action, __kindof UIView * _Nonnull sourceView, void (^ _Nonnull completionHandler)(BOOL)) {
sourceView.backgroundColor = MHColorFromHexString(@"#4c4c4c");
sourceView.superview.backgroundColor = MHColorFromHexString(@"#4c4c4c");
// Fixed Bug: 延迟一丢丢去设置 不然无效 点击需要设置颜色 不然会被重置
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.001 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
sourceView.backgroundColor = MHColorFromHexString(@"#4c4c4c");
sourceView.superview.backgroundColor = MHColorFromHexString(@"#4c4c4c");
});
completionHandler(YES);
}];
UISwipeActionsConfiguration *config = [UISwipeActionsConfiguration configurationWithActions:@[remarkAction]];
config.performsFirstActionWithFullSwipe = NO;
return config;
}
当然有兴趣的同学也可以借助: MGSwipeTableCell 来实现侧滑备注。
关于,索引条A-Z
的实现,笔者这里借助的是:SCIndexView 来实现的,关于其具体的实现,笔者这里就不再一一赘述了,有兴趣的同学可以自行学习。
项目中的配置代码如下,轻松实现微信索引Bar:
/// 监听数据
@weakify(self);
[[RACObserve(self.viewModel, letters) distinctUntilChanged] subscribeNext:^(NSArray * letters) {
@strongify(self);
if (letters.count > 1) {
self.tempView.hidden = NO;
}
self.tableView.sc_indexViewDataSource = letters;
self.tableView.sc_startSection = 1;
}];
#pragma mark - 初始化
- (void)_setup{
self.tableView.rowHeight = 56.0f;
self.tableView.backgroundColor = [UIColor clearColor];
// 配置索引模块
SCIndexViewConfiguration *configuration = [SCIndexViewConfiguration configuration];
// 设置item 距离 右侧屏幕的间距
configuration.indexItemRightMargin = 8.0;
// 设置item 文字颜色
configuration.indexItemTextColor = MHColorFromHexString(@"#555555");
// 设置item 选中时的背景色
configuration.indexItemSelectedBackgroundColor = MHColorFromHexString(@"#57be6a");
/// 设置索引之间的间距
configuration.indexItemsSpace = 4.0;
self.tableView.sc_indexViewConfiguration = configuration;
self.tableView.sc_translucentForTableViewInNavigationBar = true;
}
当然通讯录模块中,还有个细节处理,那就是滚动过程中,悬浮HeaderView渐变
,主要涉及到背景色的渐变和文字颜色的渐变。当然实现还是比较简单的,就是实现- (void)scrollViewDidScroll:(UIScrollView *)scrollView;
方法,计算headerView.mh_y
的临界点。实现如下:
// UIScrollViewDelegate
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
/// 刷新headerColor
[self _reloadHeaderViewColor];
}
/// 刷新header color
- (void)_reloadHeaderViewColor {
NSArray<NSIndexPath *> *indexPaths = self.tableView.indexPathsForVisibleRows;
for (NSIndexPath *indexPath in indexPaths) {
// 过滤
if (indexPath.section == 0) {
continue;
}
MHContactsHeaderView *headerView = (MHContactsHeaderView *)[self.tableView headerViewForSection:indexPath.section];
[self configColorWithHeaderView:headerView section:indexPath.section];
}
}
/// 配置 header color
- (void)configColorWithHeaderView:(MHContactsHeaderView *)headerView section:(NSInteger)section{
if (!headerView) {
return;
}
CGFloat insertTop = UIApplication.sharedApplication.statusBarFrame.size.height + 44;
CGFloat diff = fabs(headerView.frame.origin.y - self.tableView.contentOffset.y - insertTop);
CGFloat headerHeight = 33.0f;
double progress;
if (diff >= headerHeight) {
progress = 1;
}else {
progress = diff / headerHeight;
}
[headerView configColorWithProgress:progress];
}
/// MHContactsHeaderView.m
- (void)configColorWithProgress:(double)progress {
static NSMutableArray<NSNumber *> *textColorDiffArray;
static NSMutableArray<NSNumber *> *bgColorDiffArray;
static NSArray<NSNumber *> *selectTextColorArray;
static NSArray<NSNumber *> *selectBgColorArray;
if (textColorDiffArray.count == 0) {
UIColor *selectTextColor = MHColorAlpha(87, 190, 106, 1);
UIColor *textColor = MHColorAlpha(59, 60, 60, 1);
// 悬浮背景色
UIColor *selectBgColor = [UIColor whiteColor];
// 默认背景色
UIColor *bgColor = MHColorAlpha(237, 237, 237, 1);
selectTextColorArray = [self getRGBArrayByColor:selectTextColor];
NSArray<NSNumber *> *textColorArray = [self getRGBArrayByColor:textColor];
selectBgColorArray = [self getRGBArrayByColor:selectBgColor];
NSArray<NSNumber *> *bgColorArray = [self getRGBArrayByColor:bgColor];
textColorDiffArray = @[].mutableCopy;
bgColorDiffArray = @[].mutableCopy;
for (int i = 0; i < 3; i++) {
double textDiff = selectTextColorArray[i].doubleValue - textColorArray[i].doubleValue;
[textColorDiffArray addObject:@(textDiff)];
double bgDiff = selectBgColorArray[i].doubleValue - bgColorArray[i].doubleValue;
[bgColorDiffArray addObject:@(bgDiff)];
}
}
NSMutableArray<NSNumber *> *textColorNowArray = @[].mutableCopy;
NSMutableArray<NSNumber *> *bgColorNowArray = @[].mutableCopy;
for (int i = 0; i < 3; i++) {
double textNow = selectTextColorArray[i].doubleValue - progress * textColorDiffArray[i].doubleValue;
[textColorNowArray addObject:@(textNow)];
double bgNow = selectBgColorArray[i].doubleValue - progress * bgColorDiffArray[i].doubleValue;
[bgColorNowArray addObject:@(bgNow)];
}
UIColor *textColor = [self getColorWithRGBArray:textColorNowArray];
self.letterLabel.textColor = textColor;
UIColor *bgColor = [self getColorWithRGBArray:bgColorNowArray];
self.contentView.backgroundColor = bgColor;
}
- (NSArray<NSNumber *> *)getRGBArrayByColor:(UIColor *)color
{
CGColorSpaceRef rgbColorSpace = CGColorSpaceCreateDeviceRGB();
unsigned char resultingPixel[4];
CGContextRef context = CGBitmapContextCreate(&resultingPixel, 1, 1, 8, 4, rgbColorSpace, (CGBitmapInfo)kCGImageAlphaNoneSkipLast);
CGContextSetFillColorWithColor(context, [color CGColor]);
CGContextFillRect(context, CGRectMake(0, 0, 1, 1));
CGContextRelease(context);
CGColorSpaceRelease(rgbColorSpace);
double components[3];
for (int component = 0; component < 3; component++) {
components[component] = resultingPixel[component] / 255.0f;
}
double r = components[0];
double g = components[1];
double b = components[2];
return @[@(r),@(g),@(b)];
}
- (UIColor *)getColorWithRGBArray:(NSArray<NSNumber *> *)array {
return [UIColor colorWithRed:array[0].doubleValue green:array[1].doubleValue blue:array[2].doubleValue alpha:1];
}
细节处理:由于要后期需要弹出 搜索模块和收回搜索模块,所以要保证滚动到最顶部时,要确保搜索框完全显示或者完全隐藏,否则就会导致在弹出搜索模块,然后收回搜索模块,会导致动画不流畅,影响用户体验,微信想必也是考虑到如此场景,可以说是,细节满满。
解决方案也比较简单:判断列表停止滚动后scrollView.contentOffset.y
是否在(-scrollView.contentInset.top, -scrollView.contentInset.top + searchBarH)
范围内,判断当前是上拉还是下拉,上拉隐藏,下拉显示。 代码如下:
/// 细节处理:
/// 由于要弹出 搜索模块,所以要保证滚动到最顶部时,要确保搜索框完全显示或者完全隐藏,
/// 不然会导致弹出搜索模块,然后收回搜索模块,会导致动画不流畅,影响体验,微信做法也是如此
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView{
/// 注意:这个方法不一定调用 当你缓慢拖动的时候是不会调用的
[self _handleSearchBarOffset:scrollView];
}
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
// 记录刚开始拖拽的值
self.startDragOffsetY = scrollView.contentOffset.y;
}
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
// 记录刚开始拖拽的值
self.endDragOffsetY = scrollView.contentOffset.y;
// decelerate: YES 说明还有速度或者说惯性,会继续滚动 停止时调用scrollViewDidEndDecelerating
// decelerate: NO 说明是很慢的拖拽,没有惯性,不会调用 scrollViewDidEndDecelerating
if (!decelerate) {
[self _handleSearchBarOffset:scrollView];
}
}
/// 处理搜索框显示偏移
- (void)_handleSearchBarOffset:(UIScrollView *)scrollView {
// 获取当前偏移量
CGFloat offsetY = scrollView.contentOffset.y;
CGFloat searchBarH = 56.0f;
/// 在这个范围内
if (offsetY > -scrollView.contentInset.top && offsetY < (-scrollView.contentInset.top + searchBarH)) {
// 判断上下拉
if (self.endDragOffsetY > self.startDragOffsetY) {
// 上拉 隐藏
CGPoint offset = CGPointMake(0, -scrollView.contentInset.top + searchBarH);
[self.tableView setContentOffset:offset animated:YES];
} else {
// 下拉 显示
CGPoint offset = CGPointMake(0, -scrollView.contentInset.top);
[self.tableView setContentOffset:offset animated:YES];
}
}
}
以上就是微信通讯录模块所涉及到的全部知识点,且难度一般。当然,通讯录模块
还有个重要功能--搜索
。⚠️尽管笔者已经在 WeChat 项目中实现了,且效果跟微信如出一撤👍。 但是考虑到其逻辑的复杂性,以及UI的搭建等问题,后期笔者会单独写一篇文章,来详细描述搜索模块
的技术实现和细节处理。敬请期待...
期待
- 文章若对您有些许帮助,请给个喜欢❤️,毕竟码字不易;若对您没啥帮助,请给点建议💗,切记学无止境。
- 针对文章所述内容,阅读期间任何疑问;请在文章底部评论指出,我会火速解决和修正问题。
- GitHub地址:https://github.com/CoderMikeHe
- 源码地址:WeChat
主页
GitHub | 掘金 | CSDN | 知乎 |
---|---|---|---|
点击进入 | 点击进入 | 点击进入 | 点击进入 |