美文网首页IOS
iOS 玩转微信——通讯录

iOS 玩转微信——通讯录

作者: CoderMikeHe | 来源:发表于2020-05-28 19:47 被阅读0次

概述

  • 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

ios_contacts_page.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

由于后期考虑到,搜索模块需要增加本地搜索联系人的需求,所以本项目这里采用了内部已经封装好 PinYin4ObjcHighlightedSearch,它支持搜索关键字,高亮显示,支持汉字、全拼、简拼搜索,支持多音字搜索。

汉子转拼音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的背景色为透明色,然后添加一个白色背景的UIViewtableView的下面即可,默认隐藏,等有数据时才去显示。实现代码如下:

/// 添加一个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,立即遍历tableViewsubView,然后找到对应的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的搭建等问题,后期笔者会单独写一篇文章,来详细描述搜索模块的技术实现和细节处理。敬请期待...

期待

  1. 文章若对您有些许帮助,请给个喜欢❤️,毕竟码字不易;若对您没啥帮助,请给点建议💗,切记学无止境。
  2. 针对文章所述内容,阅读期间任何疑问;请在文章底部评论指出,我会火速解决和修正问题。
  3. GitHub地址:https://github.com/CoderMikeHe
  4. 源码地址:WeChat

主页

GitHub 掘金 CSDN 知乎
点击进入 点击进入 点击进入 点击进入

参考链接

相关文章

网友评论

    本文标题:iOS 玩转微信——通讯录

    本文链接:https://www.haomeiwen.com/subject/pbqjeftx.html