美文网首页UI
iOS 玩转微信——下拉小程序

iOS 玩转微信——下拉小程序

作者: CoderMikeHe | 来源:发表于2020-08-04 21:43 被阅读0次

    ⭐️ 概述

    • 本文笔者将手把手带领大家像素级还原微信下拉小程序的实现过程。尽量通过简单易懂的言语,以及配合关键代码,详细讲述该功能实现过程中所运用到的技术和实现细节,以及遇到问题后如何解决的心得体会。希望正有此功能需要的小伙伴们,能够通过阅读本文后,能快速将此功能真正运用到实际项目开发中去。

    • 当然,笔者的实现方案不一定是微信官方的实现,毕竟一千个观众眼中有一千个潘金莲,但是,不管黑猫白猫,能捉老鼠的就是好猫,若能够实现此功能,相信也是一个不错的方案。希望该篇文章能为大家提供一点思路,少走一些弯路,填补一些细坑。文章仅供大家参考,若有不妥之处,还望不吝赐教,欢迎批评指正。

    • 源码地址:WeChat

    🌈 预览

    ios_mainframe_pulldown_applet_page.gif

    🔎 分析

    📦 模块

    • 三个球指示模块: 微信主页下拉时,用于指示用户的下拉处于哪个阶段。(MHBouncyBallsView.h/m)

    • 小程序模块: 展示我的小程序最近使用的小程序,以及搜索小程序的功能。(MHPulldownAppletViewController.h/m)

    • 云层模块: 背景云层展示。(WHWeatherView.h/m)

    • 小程序容器模块: 承载小程序模块云层模块、蒙版,以及处理上拉滚动逻辑。(MHPulldownAppletWrapperViewController.h/m)

    • 微信首页模块: 承载小程序容器模块,展示首页内容,以及处理下拉滚动逻辑。(MHMainFrameViewController.h/m)

    🚩 阶段

    本功能主要涵盖两大阶段:下拉显示小程序阶段上拉隐藏小程序阶段;当然,用户手指上拉或下拉阶段都涉及到以下三种状态:

    • MHRefreshStateIdle: 普通闲置状态(默认)
    • MHRefreshStatePulling: 松开就可以进行刷新的状态
    • MHRefreshStateRefreshing: 正在刷新中的状态

    这里简要讲讲微信上拉或下拉进入MHRefreshStatePulling状态的条件:

    • 下拉阶段: 下拉超过临界点,且保证必须是下拉状态,即: 当前下拉偏移量 > 上一次的下拉偏移量。
    • 上拉阶段: 保证必须是上拉状态,即: 当前上拉偏移量 > 上一次的上拉偏移量,或者 偏移量为零且下拉。

    松手检测:

    • 下拉阶段: 可以利用scrollView.isDragging来检测即可。
    • 上拉阶段: scrollView.isDragging 这个属性不好使,后面会给出替代方案。

    📌 方案

    考虑到小程序容器模块小程序模块的UI页面复杂、业务逻辑繁琐,以及涉及到模块下钻等场景,这里采用父子控制器的方案来实现,主要用到以下API:

    • 添加子控制器
    [parentController.view addSubview:childController.view]; 
    
    [parentController addChildViewController:childController];
    
    [childController didMoveToParentViewController:parentController];
    
    • 移除子控制器
    [childController willMoveToParentViewController:nil];
    
    [childController.view removeFromSuperview];
    
    [childController removeFromParentViewController];
    

    整体的功能布局如下:

    • 小程序容器模块微信首页模块的子控制器。
    • 三个球指示模块微信首页模块的子控件。

    • 小程序模块小程序容器模块的子控制器。

    • 云层模块小程序容器模块的子控件。

    整体的层级结构如下:<从上到下>

    三个球模块 --> 小程序模块 --> 上拉UIScollView --> 云层模块 --> 黑色蒙版 --> 小程序容器模块.view --> 微信首页内容UITableView

    🚀 实现

    通过上面的层级分析和模块划分,我们可以针对下拉阶段上拉阶段,得出各个模块内部在这两个阶段分别作了怎样的处理,以及具体的实现过程。这里特别提醒❗️:分析过程看似简单的一逼,实现起来还是得细节拉满...

    ⬇️ 下拉阶段

    下拉阶段: 无非就是监听微信首页内容UITableView的滚动,首先,根据UITableView下拉拖拽过程中产生的偏移量(contentOffset.y),从而影响各个模块的UI变化;然后,根据用户手指下拉拖拽的距离,判断当前下拉过程中处于哪个状态;最后,当用户结束拖拽(松手)后,是进入普通闲置状态还是正在刷新中的状态,从而呈现不同的UI效果。这里先贴出下拉过程中的关键代码,然后根据代码来分析各个模块的具体实现:

    /// tableView 以滚动就会调用
    /// 这里的逻辑 完全可以参照 MJRefreshHeader
    - (void)scrollViewDidScroll:(UIScrollView *)scrollView {
        
        // 在刷新的refreshing状态 do nothing...
        if (self.state == MHRefreshStateRefreshing) {
            return;
        }else if(self.state == MHRefreshStatePulling && !scrollView.isDragging) {
            /// fixed bug: 这里设置最后一次的偏移量 以免回弹
            [scrollView setContentOffset:CGPointMake(0, self.lastOffsetY)];
        }
        
        
        // 当前的contentOffset
        CGFloat offsetY = scrollView.mh_offsetY;
        // 头部控件刚好出现的offsetY
        CGFloat happenOffsetY = -self.contentInset.top;
        
        // 如果是向上滚动到看不见头部控件,直接返回
        // >= -> >
        if (offsetY > happenOffsetY) return;
        
        // 普通 和 即将刷新 的临界点
        CGFloat normal2pullingOffsetY = - MHPulldownAppletCriticalPoint1 ;
        
        /// 计算偏移量 正数
        CGFloat delta = -(offsetY - happenOffsetY);
        
        // 如果正在拖拽
        if (scrollView.isDragging) {
            
            /// 更新 navBar 的 y
            [self.navBar mas_updateConstraints:^(MASConstraintMaker *make) {
                make.top.equalTo(self.view).with.offset(delta);
            }];
            
            /// 更新 ballsView 的 h
            [self.ballsView mas_updateConstraints:^(MASConstraintMaker *make) {
                CGFloat height = delta;
                make.height.mas_equalTo(MAX(6.0f, height));
            }];
            
            /// 传递offset
            self.viewModel.ballsViewModel.offsetInfo = @{@"offset": @(delta), @"state": @(self.state), @"animate": @NO};;
            
            /// 微信方案:不仅要下拉超过临界点 而且 保证是下拉状态:当前偏移量 > 上一次偏移量
            if (self.state == MHRefreshStateIdle && -delta < normal2pullingOffsetY && offsetY < self.lastOffsetY) {
                // 转为即将刷新状态
                self.state = MHRefreshStatePulling;
            } else if (self.state == MHRefreshStatePulling && (-delta >= normal2pullingOffsetY || offsetY >= self.lastOffsetY)) {
                // 转为普通状态
                self.state = MHRefreshStateIdle;
            }
            
            /// 传递状态
            self.viewModel.appletWrapperViewModel.offsetInfo = @{@"offset": @(delta), @"state": @(self.state)};
            
            /// 记录偏移量
            self.lastOffsetY = offsetY;
            
        } else if (self.state == MHRefreshStatePulling) {
            
            self.lastOffsetY = .0f;
            
            self.state = MHRefreshStateRefreshing;
        } else {
            /// 更新 navBar y
            [self.navBar mas_updateConstraints:^(MASConstraintMaker *make) {
                make.top.equalTo(self.view).with.offset(delta);
            }];
            
            /// 更新 ballsView 的 h
            [self.ballsView mas_updateConstraints:^(MASConstraintMaker *make) {
                CGFloat height = delta;
                make.height.mas_equalTo(MAX(6.0f, height));
            }];
            
            /// 传递offset
            self.viewModel.ballsViewModel.offsetInfo = @{@"offset": @(delta), @"state": @(self.state), @"animate": @NO};
            
            /// 传递状态
            self.viewModel.appletWrapperViewModel.offsetInfo = @{@"offset": @(delta), @"state": @(self.state)};
            
            /// 记录偏移量
            self.lastOffsetY = offsetY;
        }
    }
    
    #pragma mark - Setter & Getter
    - (void)setState:(MHRefreshState)state {
        MHRefreshState oldState = self.state;
        if (state == oldState) return;
        _state = state;
        
        // 根据状态做事情
        if (state == MHRefreshStateIdle) {
            if (oldState != MHRefreshStateRefreshing) return;
            
            /// 动画过程中 禁止用户交互
            self.view.userInteractionEnabled = NO;
            
            /// 更新位置
            [self.navBar mas_updateConstraints:^(MASConstraintMaker *make) {
                make.top.equalTo(self.view).with.offset(0);
            }];
            
            /// 更新 ballsView 的 h
            [self.ballsView mas_updateConstraints:^(MASConstraintMaker *make) {
                CGFloat height = 0;
                make.height.mas_equalTo(MAX(6.0f, height));
            }];
            
            /// 传递offset
            self.viewModel.ballsViewModel.offsetInfo = @{@"offset": @(0), @"state": @(state), @"animate": @YES};
            
            // 先置位到最底下 后回到原始位置; 因为小程序 下钻到下一模块 tabBar会回到之前的位置
            self.tabBarController.tabBar.mh_y = MH_SCREEN_HEIGHT;
            self.tabBarController.tabBar.alpha = .0f;
            
            [UIView animateWithDuration:MHPulldownAppletRefreshingDuration animations:^{
                /// 导航栏相关 回到原来位置
    //            self.tabBarController.tabBar.hidden = NO;
                self.tabBarController.tabBar.alpha = 1.0f;
                self.tabBarController.tabBar.mh_y = MH_SCREEN_HEIGHT - self.tabBarController.tabBar.mh_height;
                
                /// 设置tableView y
                self.tableView.mh_y = 0;
                
                [self.view layoutIfNeeded];
                self.navBar.backgroundView.backgroundColor = MH_MAIN_BACKGROUNDCOLOR;
            } completion:^(BOOL finished) {
                
                /// 完成后 传递数据给
                self.tableView.showsVerticalScrollIndicator = YES;
                /// 动画结束 允许用户交互
                self.view.userInteractionEnabled = YES;
            }];
        } else if (state == MHRefreshStateRefreshing) {
            dispatch_async(dispatch_get_main_queue(), ^{
                
                /// 隐藏滚动条
                self.tableView.showsVerticalScrollIndicator = NO;
                
                /// 传递offset 正向下拉
                self.viewModel.ballsViewModel.offsetInfo = @{@"offset": @(MH_SCREEN_HEIGHT - MH_APPLICATION_TOP_BAR_HEIGHT), @"state": @(self.state), @"animate": @NO};
                
                /// 传递状态
                self.viewModel.appletWrapperViewModel.offsetInfo = @{@"offset": @(MH_SCREEN_HEIGHT - MH_APPLICATION_TOP_BAR_HEIGHT), @"state": @(self.state)};
                
                /// 最终停留点的位置
                CGFloat top = MH_SCREEN_HEIGHT;
                /// 更新位置
                [self.navBar mas_updateConstraints:^(MASConstraintMaker *make) {
                    make.top.equalTo(self.view).with.offset(top - MH_APPLICATION_TOP_BAR_HEIGHT);
                }];
                
                /// 动画过程中 禁止用户交互
                self.view.userInteractionEnabled = NO;
                
                /// 动画
                [UIView animateWithDuration:MHPulldownAppletRefreshingDuration animations:^{
                    [self.view layoutIfNeeded];
                    
                    // 增加滚动区域top
                    self.tableView.mh_insetT = top;
                    
                    // ⚠️ FBI Warning:
                    // Xcode Version 11.4.1 设置animated: NO 也不好使 总之下面这两个方法都不好使
                    // Xcode Version 10.2.1 设置animated: NO 却好使
                    /// 妥协处理:这里统一用 animated: Yes 来处理 然后控制动画时间 与 scrollView 的 setContentOffset:animated: 相近即可
                    // 设置滚动位置 animated:YES 然后
                    [self.tableView setContentOffset:CGPointMake(0, -top) animated:YES];
                    /// 按照这个方式 会没有动画 tableView 会直接掉下去
    //                [self.tableView setContentOffset:CGPointMake(0, -top)];
                    
                    /// - [iphone – UIScrollview setContentOffset与非线性动画?](http://www.voidcn.com/article/p-glnejqrs-bsv.html)
                    /// - [iphone – 更改setContentOffset的速度:animated:?](http://www.voidcn.com/article/p-bgupiewh-bsr.html)
                    
                    self.navBar.backgroundView.backgroundColor = [UIColor whiteColor];
                    
                    /// 这种方式没啥动画
    //                self.tabBarController.tabBar.hidden = YES;
                    /// 这种方式有动画
                    self.tabBarController.tabBar.alpha = .0f;
                    self.tabBarController.tabBar.mh_y = MH_SCREEN_HEIGHT;
                    
                } completion:^(BOOL finished) {
                    
                    /// 小tips: 这里动画完成后 将tableView 的 y 设置到 MH_SCREEN_HEIGHT - finalTop ; 以及 将contentInset 和 contentOffset 回到原来的位置
                    /// 目的:后期上拉的时候 只需要改变tableView 的 y就行了
                    CGFloat finalTop = self.contentInset.top;
                    self.tableView.mh_y = MH_SCREEN_HEIGHT - finalTop;
                    // 增加滚动区域top
                    self.tableView.mh_insetT = finalTop;
                    // 设置滚动位置
                    [self.tableView setContentOffset:CGPointMake(0, -finalTop) animated:NO];
                    /// 动画结束 允许用户交互
                    self.view.userInteractionEnabled = YES;
                }];
            });
        }
    }
    

    微信首页

    下拉拖拽过程:即scrollView.isDragging == YES,该过程主要是:1、修改自定义导航栏的Y值。 2、计算当前下过过程中处于什么状态(Pullingor Idle)。3、传递偏移量状态三个球指示模块下拉程序容器模块

    下拉松手过程:即scrollView.isDragging ==NO,如果下拉拖拽过程中的状态时Pulling,那么松手的瞬间会进入到Refreshing;反之,则回弹到原始下拉过程中,即默认状态(Idle)。

    刷新状态逻辑:手指释放:下拉状态由 Pulling --> Refreshing,该过程主要都是动画过渡:1、导航栏的动画过渡到最底部以及修改背景色。2、UITableView内容页过渡到最底部。 3、UITabBar动画过渡到屏幕的最底部。4、传递偏移量状态三个球指示模块下拉程序容器模块

    ❗️❗️❗️细节处理如下👇:
    Q1:由于下拉过程到达Pulling状态,立即松手,UITableView会回弹一点点,导致进入RefreshingTableView动画过渡不够丝滑。
    A1:加个判断,逻辑如下

    // 在刷新的refreshing状态 do nothing...
    if (self.state == MHRefreshStateRefreshing) {
        return;
    }else if(self.state == MHRefreshStatePulling && !scrollView.isDragging) {
        /// fixed bug: 这里设置最后一次的偏移量 以免回弹
        [scrollView setContentOffset:CGPointMake(0, self.lastOffsetY)];
    }
    

    Q2:下拉状态由 Pulling --> Refreshing,过渡动画阶段禁止用户交互,以免此状态下用户上拉或下拉,导致界面紊乱。(PS:目前微信App你上拉下拉,就会导致Refreshing状态下,UITabBar依然显示的Bug)
    A2:动画开始前:self.view.userInteractionEnabled = NO; 动画完成后:self.view.userInteractionEnabled = YES;

    Q3:下拉拖拽过程的Pulling状态判断,下拉超过临界点,且保证必须是下拉状态,即: 当前下拉偏移量 > 上一次的下拉偏移量。这是微信官方App的做法:若下拉超过临界点,然后你上拉一段距离,并且此时偏移量依然超过临界点,此时松手时下拉状态为Idle,而不是Puling。若设置为Puling,那么会进入Refreshing,进行过渡动画,内容页TableView回先向上回弹,然后再掉下去,即动画过渡不够丝滑。
    A3:判断条件如下:

    /// 微信方案:不仅要下拉超过临界点 而且 保证是下拉状态:当前偏移量 > 上一次偏移量
    if (self.state == MHRefreshStateIdle && -delta < normal2pullingOffsetY && offsetY < self.lastOffsetY) {
        // 转为即将刷新状态
        self.state = MHRefreshStatePulling;
    } else if (self.state == MHRefreshStatePulling && (-delta >= normal2pullingOffsetY || offsetY >= self.lastOffsetY)) {
        // 转为普通状态
        self.state = MHRefreshStateIdle;
    }
    

    Q4TabBar动画问题。首先,下拉 Refreshing,过渡动画中,若设置hidden属性,其实没有动画的,导致隐藏的比较生硬。其次,考虑小程序模块中点击某个小程序,会下钻二级页面,由于tabBar会被系统强制显示,导致返回到主页时,tabBar依然显示的Bug;

    A4:用alpha和设置tabBar.y来代替hidden方案,这样就能形成,TabBar向下丝滑掉下的错觉。其次,根据微信主页当前的下拉状态是否为Refreshing,在viewWillAppear:viewWillDisappear:,控制其显示和隐藏。

    /// 动画
    [UIView animateWithDuration:MHPulldownAppletRefreshingDuration animations:^{
        /// 这种方式没啥动画
        /// self.tabBarController.tabBar.hidden = YES;
        /// 这种方式有动画
        self.tabBarController.tabBar.alpha = .0f;
        self.tabBarController.tabBar.mh_y = MH_SCREEN_HEIGHT;
    } completion:^(BOOL finished) {
        /// code
    }];
    
    
    - (void)viewWillAppear:(BOOL)animated {
        [super viewWillAppear:animated];
        // 这里也根据条件设置隐藏
        self.tabBarController.tabBar.alpha = (self.state == MHRefreshStateRefreshing ? .0f : 1.0f) ;
    }
    
    - (void)viewWillDisappear:(BOOL)animated {
        [super viewWillDisappear:animated];
        // 这里也根据条件设置隐藏
        self.tabBarController.tabBar.alpha = (self.state == MHRefreshStateRefreshing ? .0f : 1.0f) ;
    }
    

    Q5:微信内容页(TableView)过渡动画问题。在下拉松手进入Refreshing状态的过渡动画中,tableView也得丝滑过渡到最底部,其实实现过程,无非是设置tableView.contentInset.top = MH_SCREEN_HEIGHTtableView.contentOffset.y = - MH_SCREEN_HEIGHT,但是,理想很丰满,现实很骨感,
    我们可以将UIView的动画时间设置大一些,可以清楚的发现,内容页tableView是立即掉下去的,丝毫不见动画;当然,UIScrollView 也提供一个API动画滚动指定位置setContentOffset: animated:

    这里拓展一下:setContentOffset:setContentOffset:animated:的异同点:

    • setContentOffset:animated:这种方法,无论animatedYES还是NO, 都会等待scrollView的滚动结束以后才会执行,也就是当isDraggingisDeceleratingYES的时候,会等待滚动完成才执行上面的方法。
    • setContentOffset:这种方法则不受scrollView是否正在滚动的限制。
    • 使用animated参数,可以获得正确的UIScrollViewDelegate的回调;而使用UIView动画则不能。
      • scrollViewDidScroll:
      • scrollViewDidEndScrollingAnimation:
    • 不使用animated参数,只可以回调scrollViewDidScroll:
    • 使用animated参数,可以获取到动画过程中contentOffset的值。
    [scrollView setContentOffset:CGPointMake(0, 100) animated:YES];
    NSLog(@"%f", scrollView.contentOffset.y);//输出:0.000000
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
        NSLog(@"%f", scrollView.contentOffset.y);//输出:25.500000,每次输出不保证一致
    });
    
    • 不使用animated参数,使用UIView动画后,无论在什么时候查询contentOffset的值,得到的都是动画的最终值。
    [UIView animateWithDuration:0.25 animations:^{
        [scrollView setContentOffset:CGPointMake(0, 100)];
    }];
    NSLog(@"%f", scrollView.contentOffset.y);//输出:100.000000
    

    由于我们使用的是UIView动画,所以这里只需要在animations的代码块中设置[self.tableView setContentOffset:CGPointMake(0, -MH_SCREEN_HEIGHT) animated:NO];即可。但是又遇到一个神奇的Bug,在Xcode Version 10.2.1设置animated: NO可以实现tableView丝滑落下,然而在Xcode Version 11.4.1设置animated: NO却是直接掉下。笔者测试还发现,如果设置[self.tableView setContentOffset:CGPointMake(0, -400) animated:NO];却不受Xcode版本限制,这又是为何?? 有知道原因的小伙伴,请私信笔者哈。

    A5:考虑到上述的原因后,笔者最后用了个妥协的方法,就是在animations的代码块中设置[self.tableView setContentOffset:CGPointMake(0, -MH_SCREEN_HEIGHT) animated:YES];即可,由于下拉过渡动画比较快,只需要设置UIView动画时间和setContentOffset:animated:相近即可。最终代码如下:

    /// 动画
    [UIView animateWithDuration:MHPulldownAppletRefreshingDuration animations:^{
    
        // 增加滚动区域top
        self.tableView.mh_insetT = MH_SCREEN_HEIGHT;
        
        // ⚠️ FBI Warning:
        // Xcode Version 11.4.1 设置animated: NO 也不好使 总之下面这两个方法都不好使
        // Xcode Version 10.2.1 设置animated: NO 却好使
        /// 妥协处理:这里统一用 animated: Yes 来处理 然后控制动画时间 与 scrollView 的 setContentOffset:animated: 相近即可
        // 设置滚动位置 animated:YES 然后
        [self.tableView setContentOffset:CGPointMake(0, -MH_SCREEN_HEIGHT) animated:YES];
        /// 按照这个方式 会没有动画 tableView 会直接掉下去
        /// [self.tableView setContentOffset:CGPointMake(0, -MH_SCREEN_HEIGHT)];
        
        /// - [iphone – UIScrollview setContentOffset与非线性动画?](http://www.voidcn.com/article/p-glnejqrs-bsv.html)
        /// - [iphone – 更改setContentOffset的速度:animated:?](http://www.voidcn.com/article/p-bgupiewh-bsr.html)
        
    } completion:^(BOOL finished) {
        /// code...
    }];
    

    Q6:下拉进入Refreshing的过渡动画结束(completion)后,重置tableViewcontentInsetcontentOffset和初始转态一致;这样方便上拉拖动时,只需要修改tableView.y的值即可,无需关注contentInsetcontentOffset的设置。
    A6:代码如下:

    /// 动画
    [UIView animateWithDuration:MHPulldownAppletRefreshingDuration animations:^{
    
        // 增加滚动区域top
        self.tableView.mh_insetT = MH_SCREEN_HEIGHT;
        
        // ⚠️ FBI Warning:
        // Xcode Version 11.4.1 设置animated: NO 也不好使 总之下面这两个方法都不好使
        // Xcode Version 10.2.1 设置animated: NO 却好使
        /// 妥协处理:这里统一用 animated: Yes 来处理 然后控制动画时间 与 scrollView 的 setContentOffset:animated: 相近即可
        // 设置滚动位置 animated:YES 然后
        [self.tableView setContentOffset:CGPointMake(0, -MH_SCREEN_HEIGHT) animated:YES];
        /// 按照这个方式 会没有动画 tableView 会直接掉下去
        /// [self.tableView setContentOffset:CGPointMake(0, -MH_SCREEN_HEIGHT)];
        
        /// - [iphone – UIScrollview setContentOffset与非线性动画?](http://www.voidcn.com/article/p-glnejqrs-bsv.html)
        /// - [iphone – 更改setContentOffset的速度:animated:?](http://www.voidcn.com/article/p-bgupiewh-bsr.html)
        
    } completion:^(BOOL finished) {
        /// 小tips: 这里动画完成后 将tableView 的 y 设置到 MH_SCREEN_HEIGHT - finalTop ; 以及 将contentInset 和 contentOffset 回到原来的位置
        /// 目的:后期上拉的时候 只需要改变tableView 的 y就行了
        CGFloat finalTop = self.contentInset.top;
        self.tableView.mh_y = MH_SCREEN_HEIGHT - finalTop;
       // 增加滚动区域top
        self.tableView.mh_insetT = finalTop;
       // 设置滚动位置
       [self.tableView setContentOffset:CGPointMake(0, -finalTop) animated:NO];
    }];
    

    Q7:下拉拖拽过程中,首次进入Pulling状态时,增加振动反馈。
    A7:利用iOS 10.0提供的UIImpactFeedbackGenerator实现

    /// 微信方案:不仅要下拉超过临界点 而且 保证是下拉状态:当前偏移量 > 上一次偏移量
    if (self.state == MHRefreshStateIdle && -delta < normal2pullingOffsetY && offsetY < self.lastOffsetY) {
        // 转为即将刷新状态
        self.state = MHRefreshStatePulling;
        
        /// iOS 10.0+ 下拉增加振动反馈 https://www.jianshu.com/p/ef7eadfae188
        if (self.isFeedback) {
            /// 只震动一次
            self.feedback = NO;
            /// 开启振动反馈 iOS 10.0+
            UIImpactFeedbackGenerator *feedBackGenertor = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
            [feedBackGenertor impactOccurred];
        }
        
    } 
    

    三个球指示

    下拉拖拽过程:即scrollView.isDragging == YES,该过程主要是:1、根据下拉偏移量设置该模块的整体高度(height)。 2、根据下拉偏移量处于那几个阶段,修改内部三个球的形变(transform)和透明度(alpha)。下拉偏移量变化逻辑如下:

    • 初始阶段 => 阶段一(60):三个球的alpha都为0
    • 阶段一(60) => 阶段二(90):左右两个球的的alpha都为0。中间球的的alpha1,并且其scale值从0 -> 2
    • 阶段二(90) => 阶段三(130):左边球的alpha1,且从中心点向左平移translation.x0 -> -16;中间球的的alpha1,并且其scale值从2 -> 1;右边球的alpha1,且从中心点向右平移translation.x0 -> 16
    • 阶段三(130) => 阶段四(240):三个球的alpha值从1 -> 0
    • 阶段四(240) => ∞:整个模块的alpha0

    下拉松手 => 手指释放:若下拉状态由 Pulling --> Refreshing,下拉偏移量达到最大值屏幕高度,则模块高度为屏幕高度,由于其层级最高,为了不遮盖其他视图,需要设置自身alpha0

    以上关键代码如下:

    - (void)_handleOffset:(NSDictionary *)dictionary {
        
        CGFloat offset = [dictionary[@"offset"] doubleValue];
        MHRefreshState state = [dictionary[@"state"] doubleValue];
        
        ///
        if (state == MHRefreshStateRefreshing) {
            self.alpha = .0f;
        }else {
            self.alpha = 1.0f;
        }
     
        // 中间点相关
        CGFloat scale = 0.0;
        CGFloat alphaC = 0;
        
        // 右边点相关
        CGFloat translateR = 0.0;
        CGFloat alphaR = 0;
        
        // 左边点相关
        CGFloat translateL = 0.0;
        CGFloat alphaL = 0;
        
        if (offset > MHPulldownAppletCriticalPoint3){
            /// 超过这个 统一是 将自身隐藏
            self.alpha = .0f;
            
        } else if (offset > MHPulldownAppletCriticalPoint2) {
            // 第四阶段 1 - 0
            CGFloat step = 1.0 / (MHPulldownAppletCriticalPoint3 - MHPulldownAppletCriticalPoint2);
            double alpha = 1 - step * (offset - MHPulldownAppletCriticalPoint2);
            alpha = MAX(.0f, alpha);
            
            // 中间点阶段III: 保持scale 为1
            alphaC = alpha;
            scale = 1;
            
            // 右边点阶段III: 平移到最右侧
            alphaR = alpha;
            translateR = 16;
            
            // 左边点阶段III: 平移到最左侧
            alphaL = alpha;
            translateL = -16;
        } else if (offset > MHPulldownAppletCriticalPoint1) {
            CGFloat delta = MHPulldownAppletCriticalPoint2 - MHPulldownAppletCriticalPoint1;
            CGFloat deltaOffset = offset - MHPulldownAppletCriticalPoint1;
            
            // 中间点阶段II: 中间点缩小:2 -> 1
            CGFloat stepC = 1 / delta;
            alphaC = 1;
            scale = 2 - stepC * deltaOffset;
            
            // 右边点阶段II: 慢慢平移 0 -> 16
            CGFloat stepR = 16.0 / delta;
            alphaR = 1;
            translateR = stepR * deltaOffset;
            
            // 左边点阶段II: 慢慢平移 0 -> -16
            CGFloat stepL = -16.0 / delta;
            alphaL = 1;
            translateL = stepL * deltaOffset;
        } else if (offset > MHPulldownAppletCriticalPoint0) {
            CGFloat delta = MHPulldownAppletCriticalPoint1 - MHPulldownAppletCriticalPoint0;
            CGFloat deltaOffset = offset - MHPulldownAppletCriticalPoint0;
            
            // 中间点阶段I: 中间点放大:0 -> 2
            CGFloat step = 2 / delta;
            alphaC = 1;
            scale = 0 + step * deltaOffset;
        }
        
        self.centerBall.alpha = alphaC;
        self.centerBall.transform = CGAffineTransformMakeScale(scale, scale);
        
        self.leftBall.alpha = alphaL;
        self.leftBall.transform = CGAffineTransformMakeTranslation(translateL, 0);
        
        self.rightBall.alpha = alphaR;
        self.rightBall.transform = CGAffineTransformMakeTranslation(translateR, 0);
    }
    
    

    ❗️❗️❗️细节处理如下👇:
    Q1:该模块要监听微信首页传进来的偏移量状态,这里笔者将两者包装在一个字典中:offsetInfo = @{@"offset": xxx,@"state": ooo};这里利用RACRACObserve方法,这里千万不要设置为distinctUntilChanged,不然微量的变化,并不会触发监听事件。
    A1:正确代码如下

    @weakify(self);
    /// Fixed bug: distinctUntilChanged 不需要,否则某些场景认为没变化 实际上变化了
    RACSignal *signal = [RACObserve(self.viewModel, offsetInfo) skip:1];
    [signal subscribeNext:^(NSDictionary *dictionary) {
        @strongify(self);
         /// code....
    }];
    

    下拉小程序容器

    下拉拖拽过程:即scrollView.isDragging == YES,状态为IdlePulling,该过程主要是:根据传过来的偏移量是否超过临界点(130),来控制其自身alpha = offset > 130 ? 1.0 : .0。以及当偏移量超过130的条件下,来控制小程序模块alphascale;以及蒙版alpha值。这里分析一下此场景的逻辑。

    • 初始阶段(0) => 阶段一(130):整个模块的alpha0
    • 阶段一(130) => 阶段二(240):小程序模块alpha0 -> 0.3,以及scale.x0.6 -> 0.7scale.y0.4 -> 0.5蒙版alpha0 -> 0.3
    • 阶段二(240) => ∞:。整个模块的alpha1小程序模块alpha0.3,以及scale = {x: 0.7, y: 0.5}蒙版alpha0.3

    下拉松手 => 手指释放:若下拉状态由 Pulling --> Refreshing,进行过渡动画,整个模块的alpha过渡到1小程序模块alpha过渡到1.0,以及scale = {x: 0.6, y: 0.5}过渡到scale = {x: 1.0, y: 1.0}蒙版alpha0.3过渡到0.6云层模块alpha0.3过渡到1.0。考虑到上拉时滚动条比较短,证明内容比较长,这里设置scrollViewcontentSize.height较大即可。即self.scrollView.contentSize = CGSizeMake(0, 20 * MH_SCREEN_HEIGHT);

    关键代码如下:

    #pragma mark - 事件处理Or辅助方法
    - (void)_handleOffset:(CGFloat)offset state:(MHRefreshState)state {
        
        if (state == MHRefreshStateRefreshing) {
            /// 释放刷新状态
            [UIView animateWithDuration:MHPulldownAppletRefreshingDuration delay:0 options:UIViewAnimationOptionCurveLinear animations:^{
                /// Fixed Bug: 这里也得显示
                self.view.alpha = 1.0f;
                
                /// 小程序相关
                self.appletController.view.alpha = 1.0f;
                self.appletController.view.transform = CGAffineTransformMakeScale(1.0, 1.0);
                /// 蒙版相关
                self.darkView.alpha = .6f;
                /// 天气相关
                self.weatherView.alpha = 1.0f;
            } completion:^(BOOL finished) {
                /// 弄高点 形成滚动条短一点的错觉
                self.scrollView.contentSize = CGSizeMake(0, 20 * MH_SCREEN_HEIGHT);
            }];
        }else {
            /// 超过这个临界点 才有机会显示
            if (offset > MHPulldownAppletCriticalPoint2) {
                /// show
                self.view.alpha = 1.0f;
                
                /// 小程序View alpha 0 --> .3f
                CGFloat alpha = 0;
                CGFloat step = 0.3 / (MHPulldownAppletCriticalPoint3 - MHPulldownAppletCriticalPoint2);
                alpha = 0 + step * (offset - MHPulldownAppletCriticalPoint2);
                self.appletController.view.alpha = MIN(.3f, alpha);
                
                /// 小程序View scale 0 --> .1f
                CGFloat scale = 0;
                CGFloat step2 = 0.1 / (MHPulldownAppletCriticalPoint3 - MHPulldownAppletCriticalPoint2);
                scale =  0 + step2 * (offset - MHPulldownAppletCriticalPoint2);
                scale = MIN(.1f, scale);
                self.appletController.view.transform = CGAffineTransformMakeScale(0.6 + scale, 0.4 + scale);
                
                /// darkView alpha 0 --> .3f
                CGFloat alpha1 = 0;
                CGFloat step1 = 0.3 / (MHPulldownAppletCriticalPoint3 - MHPulldownAppletCriticalPoint2);
                alpha1 = 0 + step1 * (offset - MHPulldownAppletCriticalPoint2);
                self.darkView.alpha = MIN(.3f, alpha1);
            }else {
                self.view.alpha = .0f;
            }
        }
    }
    

    ❗️❗️❗️细节处理如下👇:
    Q1:初始情况下,小程序模块的缩放系数为scale = {x: 0.6, y: 0.4},但是默认情况是从中心点开始缩放,导致小程序模块的顶部不会处于屏幕顶部,显然不符合实际需要。
    A1:只需要修改锚点(anchorPoint)位置即可,默认情况:anchorPoint = CGPointMake(.5, .5);所以只需要修改为顶部中间即可:anchorPoint = CGPointMake(.5, 0)。考虑到修改了view.layer.anchorPoint,会导致view.frame变化,这里内部细节大家请自行百度,这里只需要知道结论: 先设置锚点anchorPoint,再设置尺寸frame 即可。


    // 先设置锚点,在设置frame
    appletController.view.layer.anchorPoint = CGPointMake(0.5, 0);
    appletController.view.frame = CGRectMake(0, 0, MH_SCREEN_WIDTH, height);
    appletController.view.transform = CGAffineTransformMakeScale(0.6, 0.4);
    appletController.view.alpha = .0f;
    

    ⬆️ 上拉阶段

    在开始讲上拉逻辑之前,我们先分析一下小程序容器模块的页面布局和层级结构,首先,该模块存在以下子模块:

    • 小程序模块:展示用户最近使用的小程序。
    • 黑色蒙版:主要是上拉或下拉,修改其alpha值,来拖拽状态和方向,下拉时,alpha 增加,上拉时,alpha减少。
    • 云层模块:云层动态展示。
    • scrollView: 用于上拉滚动。

    层级结构(从上到下): 小程序模块 --> 上拉UIScollView --> 云层模块 --> 黑色蒙版 --> 小程序容器模块.view

    考虑到上拉过程中, 小程序模块云层模块y值,也会不停的上移,最大上移高度为屏幕的高度;这里就有个将小程序模块云层模块添加到谁身上的问题:上拉UIScrollView小程序容器模块.view

    • 方案一: 若添加在上拉UIScrollView 身上,由于小程序模块也能上拉和下拉,这样就会和上拉ScrollView的上拉或下拉手势冲突,当然,网上也有大量的解决手势冲突的方案。
    • 方案二: 若添加在小程序容器模块.view 身上,想比上面的方案,就不用担心手势冲突了,毕竟他们之间没有半毛钱关系。只需要监听scrollView的滚动,来设置他们的y`即可。真香!!

    综上所述:笔者采用方案二代码如下:

    /// 初始化子控件
    - (void)_setupSubviews{
        
        /// 蒙版
        UIView *darkView = [[UIView alloc] init];
        darkView.backgroundColor = MHColorFromHexString(@"#1b1b2e");
        darkView.alpha = .0f;
        self.darkView = darkView;
        [self.view addSubview:darkView];
        
        /// 天气
        CGRect frame = CGRectMake(0, 0, MH_SCREEN_WIDTH, MH_SCREEN_HEIGHT);
        WHWeatherView *weatherView = [[WHWeatherView alloc] init];
        weatherView.frame = frame;
        [self.view addSubview:weatherView];
        self.weatherView = weatherView;
        weatherView.alpha = .0f;
        
        /// 滚动
        UIScrollView *scrollView = [[UIScrollView alloc] init];
        self.scrollView = scrollView;
        MHAdjustsScrollViewInsets_Never(scrollView);
        [self.view addSubview:scrollView];
        /// 高度为 屏高-导航栏高度 形成滚动条在导航栏下面
        scrollView.frame = CGRectMake(0, MH_APPLICATION_TOP_BAR_HEIGHT, MH_SCREEN_WIDTH, MH_SCREEN_HEIGHT-MH_APPLICATION_TOP_BAR_HEIGHT);
        scrollView.backgroundColor = [UIColor clearColor];
        scrollView.delegate = self;
        scrollView.contentInset = UIEdgeInsetsMake(0, 0, 0, 0);
        
        /// 设置减速
        //    scrollView.decelerationRate = 0.5f;
    
        /// 添加下拉小程序模块
        CGFloat height = MH_APPLICATION_TOP_BAR_HEIGHT + (102.0f + 48.0f) * 2 + 74.0f + 50.0f;
        MHPulldownAppletViewController *appletController = [[MHPulldownAppletViewController alloc] initWithViewModel:self.viewModel.appletViewModel];
        /// 小修改: 之前是添加在 scrollView , 但是 会存在手势滚动冲突 当然也是可以解决的,但是笔者懒得很,就将其添加到 self.view
        //    [scrollView addSubview:appletController.view];
        [self.view addSubview:appletController.view];
        [self addChildViewController:appletController];
        [appletController didMoveToParentViewController:self];
        self.appletController = appletController;
        
        // 先设置锚点,在设置frame
        appletController.view.layer.anchorPoint = CGPointMake(0.5, 0);
        appletController.view.frame = CGRectMake(0, 0, MH_SCREEN_WIDTH, height);
        appletController.view.transform = CGAffineTransformMakeScale(0.6, 0.4);
        appletController.view.alpha = .0f;
    }
    

    处于小程序容器模块的从屏幕底部上拉拖拽到屏幕顶部的过程,等效于 处于微信首页模块屏幕顶部下拉拖拽到屏幕底部的过程的镜像。具体逻辑如下:

    • 相同条件:scrollview.isDragging == YES(未松手);假设屏幕高度为 = 736;假设下拉为正方向,即产生的偏移量(offset)为正数;上拉为负方向,即产生的偏移量(offset)为负数
    • 下拉到屏幕底部的拖拽过程:tableView.contentOffset.y0 ==> -736;产生的偏移量(offset0)从0 ==> 736;传给各模块的偏移量(offset1)从0 ==> 736(即:offset1 = offset0),然后各模块监听偏移量(offset1)的变化,处理自身的逻辑和样式变化。
    • 上拉到屏幕顶部的拖拽过程:scrollView.contentOffset.y0 ==> 736;产生的偏移量(offset0)从0 ==> -736。由于已处于上拉模块,证明各模块的偏移量(offset1)已处于下拉最大值:736,所以此时传给各模块的偏移量(offset1)从736 ==> 0(即:offset1 = 736 + offset0),然后各模块监听偏移量(offset1)的变化,处理自身的逻辑和样式变化

    通俗理解:默认情况下,我们手指从scrollView的顶部下拉一段距离,scrollView的内容会跟着偏移一段距离;一旦手指释放后,scrollView的内容会自动回弹到scrollView顶部。而这种松手自动回弹到顶部的过程,就等效于上面上拉到屏幕顶部的拖拽过程

    所以,处于小程序容器模块的从屏幕底部上拉拖拽到屏幕顶部的过程,涉及到微信首页模块的UI变化,这里笔者就不多逼逼了,大家逆推即可。

    小程序容器模块

    上拉拖拽过程(未松手 Pulling 或 Idle )逻辑如下:

    • 判断上拉状态(Pulling 或 Idle)。
    • 蒙版的alpha0.6 ==> 0云层模块小程序模块alpha1.0 ==> 0,以及frame.origin.y0 ==> -736
    • 回调offsetstate微信首页模块。

    上拉拖拽过程(松手 Pulling => Refreshing)逻辑如下:

    • 回调offsetstate微信首页模块,让其以及其子模块,动画过渡到下拉初始状态Idle
    • 小程序容器模块以及其子模块,动画过渡到下拉初始状态Idle。特别提醒:这里要分清动画中动画后的逻辑处理:
      • 动画中:蒙版的alpha动画过渡到0云层模块小程序模块alpha 动画过渡到 0,以及frame.origin.y过渡到-736
      • 动画后: 动画完成后,需要设置:小程序容器模块alpha = 0.0云层模块小程序模块的``frame.origin.y = 0,设置小程序模块的缩放系数为CGAffineTransformMakeScale(0.6, 0.4),以及将小程序模块中的搜索框隐藏;云层模块alpha = 0.0;设置上拉ScrollViewcontentSizecontentOffset分别为CGSizeZeroCGPointZero`

    上拉拖拽过程(松手 => Idle),比如,先上拉拖拽410的距离,紧接着下拉拖拽到400的距离,然后松手,这种场景并不会进入到Refreshing状态,而是进入Idle状态。逻辑如下:

    • 上拉scrollView400滚到0,即滚动到顶部,产生的偏移量(offset0)从-400 ==> 0。此时传给微信首页模块的的偏移量(offset1)从336 ==> 736(即:offset1 = 736 + offset0)。

    具体关键代码如下:

    #pragma mark - UIScrollViewDelegate
    - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
        /// 开始拖拽
        self.dragging = YES;
        
        /// 关掉定时器
        [self _stopTimer];
    }
    
    - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
        
        /// 结束拖拽
        self.dragging = NO;
        // decelerate: YES 说明还有速度或者说惯性,会继续滚动 停止时调用scrollViewDidEndDecelerating/scrollViewDidScroll
        // decelerate: NO  说明是很慢的拖拽,没有惯性,不会调用 scrollViewDidEndDecelerating/scrollViewDidScroll
        if (!decelerate) {
            
            /// 非释放状态 需要手动 滚动到最顶部
            if (self.state != MHRefreshStatePulling) {
                [self _startTimer];
            }else {
                /// 手动调用
                [self scrollViewDidScroll:scrollView];
            }
        }else {
            /// 非释放状态 需要手动 滚动到最顶部
            if (self.state != MHRefreshStatePulling) {
                [self _startTimer];
            }
        }
        
        
    }
    
    /// Fixed Bug:scrollView.isDragging/isTracking 手指离开屏幕 可能还是会返回 YES 巨坑
    /// 解决方案: 自己控制 dragging 状态, 方法如上
    - (void)scrollViewDidScroll:(UIScrollView *)scrollView{
        
        /// 是否下拉
        BOOL isPulldown = NO;
        
        /// 获取偏移量
        CGFloat offsetY = scrollView.mh_offsetY;
        
        /// 这种场景 设置scrollView.contentOffset.y = 0 否则滚动条下拉 让用户觉得能下拉 但是又没啥意义 体验不好
        if (offsetY < -scrollView.contentInset.top) {
            scrollView.contentOffset = CGPointMake(0, -scrollView.contentInset.top);
            offsetY = 0;
            isPulldown = YES;
        }
        
        ///  微信只要滚动 结束拖拽 就立即进入刷新状态
        // 在刷新的refreshing状态 do nothing...
        if (self.state == MHRefreshStateRefreshing) {
            return;
        }
        
        /// 计算偏移量 负数
        CGFloat delta = -offsetY;
        
        // 如果正在拖拽
        if (self.isDragging) {
            
            CGFloat progress = MAX(MH_SCREEN_HEIGHT - offsetY, 0) / MH_SCREEN_HEIGHT;
            
            /// 更新 self.darkView.alpha 最大也只能拖拽 屏幕高
            self.darkView.alpha = 0.6 * progress;
            
            /// 更新 天气/小程序 的Y 和 alpha
            self.weatherView.mh_y = self.appletController.view.mh_y = delta;
            self.weatherView.alpha = self.appletController.view.alpha = 1.0f * progress;
            
            /// 必须是上拉
            if (self.state == MHRefreshStateIdle && (offsetY > self.lastOffsetY || isPulldown )) {
                // 转为即将刷新状态
                self.state = MHRefreshStatePulling;
            }else if (self.state == MHRefreshStatePulling && (offsetY <= self.lastOffsetY)){
                self.state = MHRefreshStateIdle;
            }
            
            /// 回调数据
            !self.viewModel.callback?:self.viewModel.callback( @{@"offset": @(delta), @"state": @(self.state)});
        } else if (self.state == MHRefreshStatePulling) {
            /// 进入帅新状态
            self.state = MHRefreshStateRefreshing;
        }
        
        
        /// 记录
        self.lastOffsetY = offsetY;
    }
    /**
     */
    #pragma mark - Setter & Getter
    - (void)setState:(MHRefreshState)state {
        MHRefreshState oldState = self.state;
        if (state == oldState) return;
        _state = state;
        
        // 根据状态做事情
        if (state == MHRefreshStateIdle) {
            if (oldState != MHRefreshStateRefreshing) return;
            
            // 恢复inset和offset
            [UIView animateWithDuration:.4f animations:^{
                /// 更新 天气/小程序 的Y
                self.weatherView.mh_y = self.appletController.view.mh_y = -MH_SCREEN_HEIGHT;
                
                self.darkView.alpha = .0f;
                
                self.weatherView.alpha = self.appletController.view.alpha = .0f;
                
            } completion:^(BOOL finished) {
                ///  --- 动画结束后做的事情 ---
                /// 隐藏当前view
                self.view.alpha = .0f;
                
                /// 重新调整 天气、小程序 的 y 值
                self.weatherView.mh_y = self.appletController.view.mh_y = 0;
                
                /// 重新将scrollView 偏移量 置为 0
                self.scrollView.contentOffset = CGPointZero;
                self.scrollView.contentSize = CGSizeZero;
                
                /// 重新设置 小程序view的缩放量
                self.appletController.view.transform = CGAffineTransformMakeScale(0.6, 0.4);
                [self.appletController resetOffset];
                
                /// 配置天气类型
                static NSInteger type = 0;
                type = (type + 1) % 5;
                /// 天气动画;
                [self.weatherView showWeatherAnimationWithType:type];
                self.weatherView.alpha = .0f;
                
            }];
        } else if (state == MHRefreshStateRefreshing) {
            dispatch_async(dispatch_get_main_queue(), ^{
                /// 传递状态
                /// 回调数据 offset info
                !self.viewModel.callback?:self.viewModel.callback( @{@"offset": @(-MH_SCREEN_HEIGHT), @"state": @(self.state)});
    
                /// 自身也进入空闲状态
                self.state = MHRefreshStateIdle;
            });
        }
    }
    

    ❗️❗️❗️细节处理如下👇:
    Q1:上拉松手检测,下拉时我们通过scrollView.isDragging == NO证明用户松手了;但是上拉时scrollView.isDragging/isTracking,松手了都依然是YES
    A1:解决方法,监听UIScrollViewDelegate开始拖拽结束拖拽的两大代理方法即可。

    #pragma mark - UIScrollViewDelegate
    - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
        /// 开始拖拽
        self.dragging = YES;
    }
    - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{   
        /// 结束拖拽
        self.dragging = NO; 
    }
    

    Q2:上拉拖拽Pulling状态检测。微信官方做法如下:

    • 如果scrollView内容处于最顶部,即scrollView.contentOffset.y == 0,紧接着下拉,理论上scrollView.contentOffset.y会小于0,这种情况会进入Pulling状态,当然,这种情况,微信会重置scrollView.contentOffset.y会等于0
    • 如果scrollView上拉,即scrollView.contentOffset.y > 0,并且保证是上拉情况,即当前scrollView.contentOffset.y大于上一次的 scrollView.contentOffset.y

    A2:处理方案如下:

    /// Fixed Bug:scrollView.isDragging/isTracking 手指离开屏幕 可能还是会返回 YES 巨坑
    /// 解决方案: 自己控制 dragging 状态, 方法如上
    - (void)scrollViewDidScroll:(UIScrollView *)scrollView{
        
        /// 是否下拉
        BOOL isPulldown = NO;
        
        /// 获取偏移量
        CGFloat offsetY = scrollView.mh_offsetY;
        
        /// 这种场景 设置scrollView.contentOffset.y = 0 否则滚动条下拉 让用户觉得能下拉 但是又没啥意义 体验不好
        if (offsetY < -scrollView.contentInset.top) {
            scrollView.contentOffset = CGPointMake(0, -scrollView.contentInset.top);
            offsetY = 0;
            isPulldown = YES;
        }
        
        ///  微信只要滚动 结束拖拽 就立即进入刷新状态
        // 在刷新的refreshing状态 do nothing...
        if (self.state == MHRefreshStateRefreshing) {
            return;
        }
        
        /// 计算偏移量 负数
        CGFloat delta = -offsetY;
        
        // 如果正在拖拽
        if (self.isDragging) {
            
            CGFloat progress = MAX(MH_SCREEN_HEIGHT - offsetY, 0) / MH_SCREEN_HEIGHT;
            
            /// 更新 self.darkView.alpha 最大也只能拖拽 屏幕高
            self.darkView.alpha = 0.6 * progress;
            
            /// 更新 天气/小程序 的Y 和 alpha
            self.weatherView.mh_y = self.appletController.view.mh_y = delta;
            self.weatherView.alpha = self.appletController.view.alpha = 1.0f * progress;
            
            /// 必须是上拉
            if (self.state == MHRefreshStateIdle && (offsetY > self.lastOffsetY || isPulldown )) {
                // 转为即将刷新状态
                self.state = MHRefreshStatePulling;
            }else if (self.state == MHRefreshStatePulling && (offsetY <= self.lastOffsetY)){
                self.state = MHRefreshStateIdle;
            }
            
            /// 回调数据
            !self.viewModel.callback?:self.viewModel.callback( @{@"offset": @(delta), @"state": @(self.state)});
        } else if (self.state == MHRefreshStatePulling) {
            /// 进入帅新状态
            self.state = MHRefreshStateRefreshing;
        }
       
        /// 记录
        self.lastOffsetY = offsetY;
    }
    

    Q3:上拉拖拽,松手进入Idle的处理逻辑。即:先上拉拖拽410的距离,紧接着下拉拖拽到400的距离,然后松手。

    • 微信官方做法:丝滑缓慢的从scrollView.contentOffset.y = 400滚动到最顶部scrollView.contentOffset.y = 0。注意两个点:丝滑缓慢

    A3.1:相信大家的第一想法就是:利用setContentOffset:animated:来实现,只需要在结束拖拽停止减速的代理中调用即可,

    - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
        
        /// 结束拖拽
        self.dragging = NO;
        // decelerate: YES 说明还有速度或者说惯性,会继续滚动 停止时调用scrollViewDidEndDecelerating/scrollViewDidScroll
        // decelerate: NO  说明是很慢的拖拽,没有惯性,不会调用 scrollViewDidEndDecelerating/scrollViewDidScroll
        if (!decelerate) {
            /// 非释放状态 需要手动 滚动到最顶部
            if (self.state != MHRefreshStatePulling) {
                [scrollView setContentOffset:CGPointMake(0,0) animated:YES];
            }else {
                /// 手动调用
                [self scrollViewDidScroll:scrollView];
            }
        }
    }
    
    - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
        if (self.state != MHRefreshStatePulling) {
            [scrollView setContentOffset:CGPointMake(0,0) animated:YES];
        }
    }
    

    Q3.1:上面A3.1的方案,虽然动画滚动到最顶部,但是还是存在以下几个问题:

    • 结束拖拽的代理中,并且decelerate == NO场景下,会丝滑的滚动到最顶部,但不是缓慢过渡,而是快速过渡,毕竟setContentOffset:animated:的动画时间不能手动设置。
    • 结束拖拽的代理中,并且decelerate == YES场景下,说明scrollView还有向下滚动的趋势(惯性),我们选择在scrollViewDidEndDecelerating中滚动到顶部, 过渡状态由慢到快,不满足丝滑的条件以及缓慢的条件,

    A3.2: 针对Q3.1的问题,衍生出利用UIView动画代替setContentOffset:animated:YES的场景,毕竟UIView的动画时间是可以手动设定的。方案如下:

    - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
        /// 结束拖拽
        self.dragging = NO;
        // decelerate: YES 说明还有速度或者说惯性,会继续滚动 停止时调用scrollViewDidEndDecelerating/scrollViewDidScroll
        // decelerate: NO  说明是很慢的拖拽,没有惯性,不会调用 scrollViewDidEndDecelerating/scrollViewDidScroll
        if (!decelerate) {
            /// 非释放状态 需要手动 滚动到最顶部
            if (self.state != MHRefreshStatePulling) {
                [UIView animateWithDuration:4 animations:^{
                    [scrollView setContentOffset:CGPointZero];
                }];
            }else {
                /// 手动调用
                [self scrollViewDidScroll:scrollView];
            }
        }
    }
    
    - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
        /// 因为这里已经减速完成了 所以动画时间要更久
        /// 非释放状态 需要手动 滚动到最顶部
        if (self.state != MHRefreshStatePulling) {
            [UIView animateWithDuration:10 animations:^{
                [scrollView setContentOffset:CGPointZero];
            }];
        }
    }
    

    Q3.2:上面A3.2的方案,虽然完美的解决了A3.1不够丝滑动画过快的痛点,当是还是存在以下些许不足:

    • 无法监听滚动过程中的偏移量(contentOffset)的变化,即:使用UIView动画后,无论在什么时候查询contentOffset的值,得到的都是动画的最终值CGPointZero
    • 由于上拉拖拽过程中,偏移量从0 ==> 130 这段滚动中,微信首页的导航栏的背景色由#FFFFFF 过渡到 #EDEDED,反之,如果我们用UIVIew动画,只能知道动画的最终值CGPointZero。导致导航栏一放手,导航就直接变成白色的过程,影响用户体验。

    A3.3:为了保证下滑丝滑缓慢偏移量可监听等业务逻辑,这里采取的是NSTimer,来模拟先快后慢的下滑过程,在定时器事件回调中,不断设置scrollViewcontentOffset属性,以及回调offsetstate微信首页模块,从而通过监听偏移量的变化,来处理UI。代码逻辑如下:

    #pragma mark - UIScrollViewDelegate
    - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
       /// 开始拖拽
       self.dragging = YES;
       
       /// 关掉定时器
       [self _stopTimer];
    }
    
    - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
       
       /// 结束拖拽
       self.dragging = NO;
       // decelerate: YES 说明还有速度或者说惯性,会继续滚动 停止时调用scrollViewDidEndDecelerating/scrollViewDidScroll
       // decelerate: NO  说明是很慢的拖拽,没有惯性,不会调用 scrollViewDidEndDecelerating/scrollViewDidScroll
       if (!decelerate) {
           
           /// 非释放状态 需要手动 滚动到最顶部
           if (self.state != MHRefreshStatePulling) {
               [self _startTimer];
           }else {
               /// 手动调用
               [self scrollViewDidScroll:scrollView];
           }
       }else {
           /// 非释放状态 需要手动 滚动到最顶部
           if (self.state != MHRefreshStatePulling) {
               [self _startTimer];
           }
       }
    }
    
    /// 开始定时器
    - (void)_startTimer {
       ///
       if (!self.timer && !self.timer.isValid && self.lastOffsetY > 0) {
           /// 获取当前拖拽结束d偏移量
           self.offsetValue = self.scrollView.mh_offsetY;
           
           /// 计时次数清零
           self.timerCount = 0;
           /// 模拟先快后慢 假设 快阶段:0.5s跑80%的距离 慢阶段:0.5s跑20%的距离
           NSTimeInterval interval = .01f;
           CGFloat count0 = 1.5 * 0.3/interval;
           CGFloat count1 = 1.5 * 0.7/interval;
           
           self.stepFastValue = self.offsetValue * 0.5/count0;
           self.stepSlowValue = self.offsetValue * 0.5/count1;
           
           self.timer = [YYTimer timerWithTimeInterval:interval target:self selector:@selector(_timerValueChanged:) repeats:YES];
       }
    }
    
    /// 关闭定时器 用户一旦开始拖拽 就关闭定时器
    - (void)_stopTimer {
       if (self.timer && self.timer.isValid) {
           [self.timer invalidate];
           self.timer = nil;
       }
    }
    
    
    /// 定时器回调事件
    - (void)_timerValueChanged:(YYTimer *)timer{
       /// 进来+1
       self.timerCount++;
       
       /// 设置步进值
       if (self.timerCount <= 1.5 * 0.3 / 0.01) {
           /// 快阶段
           self.offsetValue -= self.stepFastValue;
       }else {
           self.offsetValue -= self.stepSlowValue;
       }
       
       /// 滚动结束 关闭定时器
       if (self.offsetValue <= 0) {
           [timer invalidate];
           self.timer = nil;
           /// 归零
           self.offsetValue = .0f;
       }
       /// 正数
       CGFloat offset = self.offsetValue;
       
       /// 设置scrollView 的偏移量
       [self.scrollView setContentOffset:CGPointMake(0, offset)];
       
       CGFloat progress = MAX(MH_SCREEN_HEIGHT - offset, 0) / MH_SCREEN_HEIGHT;
       
       /// 更新 self.darkView.alpha 最大也只能拖拽 屏幕高
       self.darkView.alpha = 0.6 * progress;
       
       /// 更新 天气/小程序 的Y 和 alpha
       self.weatherView.mh_y = self.appletController.view.mh_y = -offset;
       self.weatherView.alpha = self.appletController.view.alpha = 1.0f * progress;
       
       /// 回调数据
       !self.viewModel.callback?:self.viewModel.callback( @{@"offset": @(-offset), @"state": @(self.state)});
    }
    
    微信模块

    这里笔者主要讲一下,上拉拖拽过程中,导航栏的颜色渐变逻辑,且这个逻辑只发生在上拉阶段(0 -- 130),下拉无需考虑其颜色变化。方案其实很简单,监听上拉偏移量的变化,
    然后不断修改导航栏背景色的RGB即可。关键代码如下:

    /// 处理拖拽时导航栏背景色变化
    /// 只处理上拉的逻辑 下拉忽略
    /// offset: 偏移量。
    - (void)_changeNavBarBackgroundColor:(CGFloat)offset{
       
       static NSDictionary *dict0;
       static NSDictionary *dict1;
       
       /// 导航栏颜色:#ededed --> #fffff
       if (!(dict0 && dict0.allKeys.count != 0)) {
           UIColor *color0 = MHColorFromHexString(@"#ededed");
           dict0 = @{@"red":@(color0.red), @"green": @(color0.green), @"blue":@(color0.blue)};
           
           UIColor *color1 = [UIColor whiteColor];
           dict1 = @{@"red":@(color1.red), @"green": @(color1.green), @"blue":@(color1.blue)};
       }
       
       CGFloat delta = fabs(offset);
       
       if (delta > MH_SCREEN_HEIGHT) {
           delta = MH_SCREEN_HEIGHT;
       }
       
       /// 进度 0 --> 1.0f
       /// 下拉 不修改导航栏颜色
       CGFloat progress = .0f;
       if (delta < MHPulldownAppletCriticalPoint2) {
           /// 上拉 0 ---> 100
           progress = 1 - delta/MHPulldownAppletCriticalPoint2;
       }
       /// 计算差值
       CGFloat red = ([dict0[@"red"] doubleValue] + progress * ([dict1[@"red"] doubleValue] - [dict0[@"red"] doubleValue])) * 255;
       CGFloat green = ([dict0[@"green"] doubleValue] + progress * ([dict1[@"green"] doubleValue] - [dict0[@"green"] doubleValue])) * 255;
       CGFloat blue = ([dict0[@"blue"] doubleValue] + progress * ([dict1[@"blue"] doubleValue] - [dict0[@"blue"] doubleValue])) * 255;
       self.navBar.backgroundView.backgroundColor = MHColor(red, green, blue);
    }
    
    
    三个小球指示

    这里讲一下上拉释放,状态由Pulling --> Refreshing的过程,由于其内部需要监听偏移量的变化,来修改三个小球的样式,但是由于这里只能监听到偏移量的最终值0
    所以,过渡动画过程中,我们根本看不到三个球的变化(即三个变成一个),仅仅只能见到三个小球,丝滑平移到屏幕顶部的过程。解决方案,同上面类似,利用定时器(NSTimer)来处理即可:

    - (void)bindViewModel:(MHBouncyBallsViewModel *)viewModel {
       self.viewModel = viewModel;
       
       @weakify(self);
       /// Fixed bug: distinctUntilChanged 不需要,否则某些场景认为没变化 实际上变化了
       RACSignal *signal = [RACObserve(self.viewModel, offsetInfo) skip:1];
       [signal subscribeNext:^(NSDictionary *dictionary) {
           @strongify(self);
           
           CGFloat offset = [dictionary[@"offset"] doubleValue];
           BOOL animate = [dictionary[@"animate"] boolValue];
           
           if (animate) {
               
               if (!self.timer && !self.timer.isValid && self.lastOffset > MHPulldownAppletCriticalPoint0) {
                   NSTimeInterval interval = .05f;
                   CGFloat count = MHPulldownAppletRefreshingDuration/interval;
                   self.stepValue = self.lastOffset/count;
                   self.timer = [YYTimer timerWithTimeInterval:interval target:self selector:@selector(_timerValueChanged:) repeats:YES];
               }
               
           } else {
               /// 记录上一次数据
               self.lastOffset = offset;
               ///
               [self _handleOffset:dictionary];
           }
       }];
    }
    
    /// 定时器为
    - (void)_timerValueChanged:(YYTimer *)timer
    {
       self.lastOffset -= self.stepValue;
       if (self.lastOffset <= 0) {
           [timer invalidate];
           self.timer = nil;
       }
       CGFloat offset = MAX(0, self.lastOffset);
       [self _handleOffset: @{@"offset" : @(offset), @"state": @(MHRefreshStateIdle), @"animate": @NO}];
    }
    
    
    小程序模块

    小程序模块的业务相对比较简单,仅仅作为展示层,但是还是有些比较细节拉满的点,可以和大家聊聊。

    ❗️❗️❗️细节处理:
    Q1:默认场景和上拉动画结束,需要隐藏搜索栏。
    A1:提供一个公用API,供外部调用

    #pragma mark - Public Method
    - (void)resetOffset {
        self.tableView.contentOffset = CGPointMake(0, 57.0f);
    }
    

    Q2:由于小程序模块的高度,没有屏幕高,如果下拉时,tableView内容会被超出(隐藏)。
    A2:下拉时(offset < 0),设置tableViewclipsToBoundsYES;但是上拉时(offset > 0),设置tableViewclipsToBoundsNO

    #pragma mark - UIScrollViewDelegate
    - (void)scrollViewDidScroll:(UIScrollView *)scrollView{
       CGFloat offset = scrollView.contentOffset.y;
       /// 不裁剪子视图
       self.tableView.clipsToBounds = offset > 0;
    }
    

    Q3:在偏移量0 ~ 搜索栏高度 = 57.0f范围内,若手指释放时,处于下拉状态,则显示搜索栏;反之,处于上拉状态,则隐藏搜索栏
    A3:处理逻辑如下:

    /// 细节处理:
    /// 由于要弹出 搜索模块,所以要保证滚动到最顶部时,要确保搜索框完全显示或者完全隐藏,
    /// 不然会导致弹出搜索模块,然后收回搜索模块,会导致动画不流畅,影响体验,微信做法也是如此
    - (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];
       }
       
       /// 处理结束后的回调
       [self _handleEndDraggingAction];
    }
    /// 处理搜索框显示偏移
    - (void)_handleSearchBarOffset:(UIScrollView *)scrollView {
       // 获取当前偏移量
       CGFloat offsetY = scrollView.contentOffset.y;
       CGFloat searchBarH = 57.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];
           }
       }
    }
    

    Q4:若在小程序模块上拉偏移量超过135.0f,手指释放后,则需要影藏小程序容器模块,类似于使小程序容器模块进入上拉释放,状态由Pulling --> Refreshing状态的逻辑。
    A4:处理逻辑如下:

    /// 小程序模块
    - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
       // 记录刚开始拖拽的值
       self.endDragOffsetY = scrollView.contentOffset.y;
       // decelerate: YES 说明还有速度或者说惯性,会继续滚动 停止时调用scrollViewDidEndDecelerating
       // decelerate: NO  说明是很慢的拖拽,没有惯性,不会调用 scrollViewDidEndDecelerating
       if (!decelerate) {
           [self _handleSearchBarOffset:scrollView];
       }
       
       /// 处理结束后的回调
       [self _handleEndDraggingAction];
    }
    /// 处理结束拖拽的事件 135.0f
    - (void)_handleEndDraggingAction {
       if (self.endDragOffsetY >= 135.0f) {
           /// 回调数据 直接回到主页
           !self.viewModel.callback ? : self.viewModel.callback(@{@"completed":@YES,@"delay":@NO});
       }
    }
    
    /// 小程序容器模块
    /// 监听小程序的回调数据
    /// completed: YES 回到主页 NO 不回到主页
    self.viewModel.appletViewModel.callback = ^(NSDictionary *dictionary) {
       @strongify(self);
       
       BOOL completed = [dictionary[@"completed"] boolValue];
       BOOL delay = [dictionary[@"delay"] boolValue];
       
       if (completed) {
           /// 增加延迟,方便等到跳转到下一页 再回到主页
           if (delay) {
               self.delay = delay;
           }else {
               self.state = MHRefreshStateRefreshing;
           }
       }
    };
    

    Q5:下钻二级页面,关闭小程序模块,以及关闭时机问题。例如:点击搜索栏,进入小程序搜索模块,这种场景无需关闭小程序模块;而点击某个小程序(王者荣耀),则进入王者荣耀小程序,则需要关闭小程序模块
    当然不能立即关闭,而是等小程序模块消失后viewDidDisappear,再去关闭,不然会导致push动画和关闭时的过渡动画共存,显得比较脏乱。
    A5:处理逻辑如下:

    - (void)viewDidDisappear:(BOOL)animated {
       [super viewDidDisappear:animated];
       
       /// 放在这里做处理 不然还是会看到动画...
       if (self.isDelay) {
           self.delay = NO;
           self.state = MHRefreshStateRefreshing;
       }
    }
    /// 监听小程序的回调数据
    /// completed: YES 回到主页 NO 不回到主页
    self.viewModel.appletViewModel.callback = ^(NSDictionary *dictionary) {
       @strongify(self);
       
       BOOL completed = [dictionary[@"completed"] boolValue];
       BOOL delay = [dictionary[@"delay"] boolValue];
       
       if (completed) {
           /// 增加延迟,方便等到跳转到下一页 再回到主页
           if (delay) {
               self.delay = delay;
           }else {
               self.state = MHRefreshStateRefreshing;
           }
       }
    };
    

    ♥️ 期待

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

    ☎️ 主页

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

    相关文章

      网友评论

        本文标题:iOS 玩转微信——下拉小程序

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