美文网首页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