iOS开发 - 手势调节音量、亮度、播放进度

最近负责播放器模块的开发,业务需求也慢慢增加中,包括梳理播放器界面上的交互、加载优化。
下面大概梳理一下,手势调节音量、亮度、播放进度等交互部分。
与其他播放器需求上相似,左右滑动用于拖拽播放进度,左右侧两边的上下滑动分别用于亮度、音量调节。这里我把代码大致梳理一下,如果有其他拖拽需求也可以沿用这种方法。
【本次开发环境:Xcode:11.2.1 iOS 真机:iPhone 8Plus By:啊左。

(小编在虎牙直播码代码,最近公司各种职位热招,需要内推的可以私聊~)

为了方便抽离调节音量/亮度,我们创建一个调节的容器(视图)集中处理,命名为 SystemAdjustView。

1、确定拖拽手势:

首先,这些交互调节的操作主要是拖拽,我们确定用 UIPanGestureRecognizer:

 UIPanGestureRecognizer *panGesture = [[UIPanGestureRecognizer alloc] initWithTarget:self
                                                                              action:@selector(panDirection:)];
 panGesture.delegate  = self;
 [self addGestureRecognizer:panGesture];

不管上下还是左右滑动,我们做判断处理,所以先定义一个滑动方向的枚举:

// 滑动方向枚举
typedef NS_ENUM(NSUInteger, SlidingDirection) {
    SlidingDirectionLeftOrRight,
    SlidingDirectionUpOrDown,
    SlidingDirectionNone
};

2、添加需要的数据变量

添加相应的调节动画视图,以及方向等属性变量:

/// 当前滑动方向
@property (nonatomic, assign) SlidingDirection slidingDirection;
/// 当前是否为音量滑动
@property (nonatomic, assign) BOOL isVolume;
/// 视图容器
@property (nonatomic, strong) UIView *justContainer;
/// 调节动画 icon
@property (nonatomic, strong) UIImageView *justImgView;
/// 调节动画文案
@property (nonatomic, strong) UILabel *justLabel;
/// 系统的音量调节视图
@property (nonatomic, strong) MPVolumeView *mpVolumeView;
/// 系统的音量调节视图辅助
@property (nonatomic, strong) UISlider *volumeViewSlider;

以下是控件的懒加载,平时都用惯 Masonry,为方便大家测试 demo,这里用 frame 计算布局:

#pragma mark - Setter && Getter
- (UIView *)justContainer {
    if (!_justContainer) {
        CGFloat x = SCREEN_WIDTH/2 - COTAINER_WIDTH/2;
        CGFloat y = SCREEN_HEIGHT/2 - COTAINER_HEIGHT/2;
        _justContainer = [[UIView alloc] initWithFrame:CGRectMake(x, y, COTAINER_WIDTH, COTAINER_HEIGHT)];
        _justContainer.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.2];
        _justContainer.layer.cornerRadius = 4;
        _justContainer.alpha = 0.0;
    }
    return _justContainer;
}

- (UILabel *)justLabel {
    if (!_justLabel) {
        _justLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, 45, COTAINER_WIDTH, 16)];
        _justLabel.textAlignment = NSTextAlignmentCenter;
        _justLabel.textColor = [UIColor whiteColor];
        _justLabel.font = [UIFont fontWithName:@"PingFangSC-Regular"size:12];
        _justLabel.textAlignment = NSTextAlignmentCenter;
    }
    return _justLabel;
}

- (UIImageView *)justImgView {
    if (!_justImgView) {
        CGFloat defaultSize = 30;
        CGFloat x = COTAINER_WIDTH/2 - defaultSize/2;
        _justImgView = [[UIImageView alloc] initWithFrame:CGRectMake(x, 10, defaultSize, defaultSize)];
    }
    return _justImgView;
}

- (MPVolumeView *)mpVolumeView {
    if (!_mpVolumeView) {
        _mpVolumeView = [[MPVolumeView alloc] init];
        [_mpVolumeView setShowsRouteButton:YES];
        // hidden 一定要设置为 NO,当然这里不设置也行,因为默认为 NO.
        _mpVolumeView.hidden = NO;
        // frame 需要在可视区域外
        [_mpVolumeView setFrame:CGRectMake(-100, -100, 40, 40)];
        [_mpVolumeView setShowsVolumeSlider:YES];

        for (UIView *view in [_mpVolumeView subviews]){
            if ([view.class.description isEqualToString:@"MPVolumeSlider"]){
                  self.volumeViewSlider =(UISlider*)view;
                [self.volumeViewSlider addTarget:self action:@selector(volumeViewSliderClick:) forControlEvents:UIControlEventTouchUpInside];
                break;
            }
        }
    }
    return _mpVolumeView;
}

分析:

① MPVolumeView 的作用?

MPVolumeView 是 MediaPlayer 框架中的一个组件,包含了对系统音量和AirPlay 设备的音频镜像路由的控制功能。MPVolumeView 有三个 subview,其中 MPVolumeSlider 是用来控制音量大小,继承自 UISlider。 所以我们可以通过创建 MPVolumeView,并拿到它 subViews 中的 UISlider 变量。
需要注意的是,因为 MPVolumeView 没有定制的功能,所以如果音量变化 UI 由我们定制的话,创建的 MPVolumeView 需要设置在可视区域之外,例如 本文 demo 设置为 CGRectMake(-100, -100, 40, 40),这样音量发生变化的时候,就只会出现我们绘制的 UI 了。
记得导入:

#import <MediaPlayer/MediaPlayer.h>

(by:MPVolumeView 变量的 hidden 属性一定要为 NO,且 frame 应该是不能直接设置为 CGRectZero 的。 )

②用户直接用 iPhone 音量键调节,如何显示我们绘制的动画?

添加 AVSystemController_SystemVolumeDidChangeNotification 音量变化通知,在通知里处理绘制响应的音量变化 UI:

// 添加系统音量观察者
[[NSNotificationCenter defaultCenter] addObserver:self
                                          selector:@selector(volumeChanged:)
                                              name:@"AVSystemController_SystemVolumeDidChangeNotification" 
                                            object:nil];

3、初始化视图控件、手势,添加监听音量变化等:

先添加需要的宏数据

// 屏幕宽高
#define SCREEN_WIDTH                [UIScreen mainScreen].bounds.size.width
#define SCREEN_HEIGHT               [UIScreen mainScreen].bounds.size.height

// 调节动画宽高
#define COTAINER_WIDTH   64
#define COTAINER_HEIGHT  72

// 滑动时间
#define SHOW_DURATION   1.0
// 隐藏延迟时间
#define HIDE_DELAY      0.8

初始化控件

#pragma mark - Life cycle
- (instancetype)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    if (self) {
        [self initViews];
        [self appendPanGesture];
    }
    return self;
}

- (void)dealloc {
    // 移除 延迟隐藏调节界面操作
    [NSObject cancelPreviousPerformRequestsWithTarget:self
                                             selector:@selector(hideContainerAnimation)
                                               object:nil];
    [[NSNotificationCenter defaultCenter] removeObserver:self
                                                    name:@"AVSystemController_SystemVolumeDidChangeNotification"
                                                  object:nil];
}

- (void)initViews {
    [self addSubview:self.justContainer];
    [self.justContainer addSubview:self.justImgView];
    [self.justContainer addSubview:self.justLabel];
    [self addSubview:self.mpVolumeView];
    
    // 需要先创建活动音频会话,然后才能调用下一行代码的音量变化事件。
    NSError *error;
    [[AVAudioSession sharedInstance] setActive:YES error:&error];

    // 添加系统音量观察者
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(volumeChanged:) name:@"AVSystemController_SystemVolumeDidChangeNotification" object:nil];
}

- (void)appendPanGesture {
    UIPanGestureRecognizer *panGesture = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panDirection:)];
    panGesture.delegate  = self;
    [self addGestureRecognizer:panGesture];
}

4、分别对用户上下、左右滑动手势进行 UI 调节处理:

#pragma mark - Private Methods

#pragma mark Horizontal Move

/// 水平方向调节开始
/// @param value 开始值
- (void)horizontalStateBeginValue:(CGFloat)value {
}

/// 水平方向调节变化时
/// @param value 变化时的值
- (void)horizontalStateChangedValue:(CGFloat)value {
}

/// 水平方向调节结束
/// @param value 结束值
- (void)horizontalStateEndValue:(CGFloat)value {
}

#pragma mark Vertical Move

/// 竖直方向调节开始
/// @param isVolume 是否为音量调节
- (void)verticalStateBeginIsVolume:(BOOL)isVolume {
    // cancel hardware volume adjustment
    [NSObject cancelPreviousPerformRequestsWithTarget:self
                                             selector:@selector(hideContainerAnimation)
                                               object:nil];
    self.isVolume = isVolume;
    [self updateVolumeIcon];

    [self.volumeViewSlider sendActionsForControlEvents:UIControlEventTouchUpInside];
}

/// 竖直方向调节变化时
/// @param value 变化值
- (void)verticalStateChangedValue:(CGFloat)value {
    if (self.isVolume) {
        // 调节系统音量
        self.volumeViewSlider.value -= value / 10000;
        [self.volumeViewSlider sendActionsForControlEvents:UIControlEventTouchUpInside];
    } else {
        // 调节系统亮度
        [UIScreen mainScreen].brightness -= value / 10000;
        _justLabel.text = [NSString stringWithFormat:@"%ld%%",(NSInteger)([UIScreen mainScreen].brightness * 100)];
    }
    [self updateVolumeIcon];
}

/// 竖直方向调节结束
- (void)verticalStateEnded {
    [self performSelector:@selector(hideContainerAnimation)
               withObject:nil
               afterDelay:HIDE_DELAY];
}

关于水平调节的,是关于播放进度等拖拽处理,读者可自行添加使用,篇幅原因播放进度拖拽这里不做讲解。
以上的两个关于 icon 更换、动画隐藏/显示视图等私有方法如下所示:

#pragma mark Common

/// 操作完毕,1s 时间隐藏动画
- (void)hideContainerAnimation {
    [UIView animateWithDuration:SHOW_DURATION animations:^{
        self.justContainer.alpha = 0.0;
    }];
}

/// 更新调节图标(音量/亮度)
- (void)updateVolumeIcon {
    NSString *imgName;
    if (self.isVolume) {
        imgName = (self.volumeViewSlider.value <= 0) ?
        @"video_system_volume_mute" : @"video_system_volume";
    } else {
        imgName = @"video_system_brightness";
    }
    [_justImgView setImage:[UIImage imageNamed:imgName]];
    
    _justContainer.alpha = 1.0;
}

5、事件处理

以下分别是 UISlider 的滑动事件和 UIPanGestureRecognizer 拖拽事件的实现:

#pragma mark - Event Click

- (void)volumeViewSliderClick:(UISlider *)volumeViewSlider {
    // 更新音量显示值
    _justLabel.text = [NSString stringWithFormat:@"%ld%%",(NSInteger)(self.volumeViewSlider.value * 100)];
}

- (void)panDirection:(UIPanGestureRecognizer *)pan {
    // 手指在视图上移动的速度,可用于判断 水平/竖直 方向滑动
    CGPoint velocityPoint = [pan velocityInView:self];
    // 手指在视图上的位置
    CGPoint locationPoint = [pan locationInView:self];
    
    switch (pan.state) {
        case UIGestureRecognizerStateBegan:{
            CGFloat x = fabs(velocityPoint.x);
            CGFloat y = fabs(velocityPoint.y);
            if (x > y) {
                // 水平方向滑动
                self.slidingDirection = SlidingDirectionLeftOrRight;
                [self horizontalStateBeginValue:locationPoint.x];
                
            } else if (x < y){
                // 竖直方向滑动
                self.slidingDirection = SlidingDirectionUpOrDown;
                if (locationPoint.x <= self.frame.size.width / 2.0) {
                    [self verticalStateBeginIsVolume:NO];
                } else {
                    [self verticalStateBeginIsVolume:YES];
                }
            }
            
            break;
        }
        case UIGestureRecognizerStateChanged:{
            // 滑动时,根据 水平/垂直方向分别进行处理
            switch (self.slidingDirection){
                case SlidingDirectionUpOrDown:{
                    [self verticalStateChangedValue:velocityPoint.y];
                    break;
                }
                case SlidingDirectionLeftOrRight:{
                    CGPoint movePoint = [pan translationInView:self];
                    [self horizontalStateChangedValue:movePoint.x];
                        break;
                    }
                default:
                    break;
            }
            break;
            
        }
        case UIGestureRecognizerStateEnded:{
            // 滑动结束时,根据 水平/垂直方向分别进行处理
            switch (self.slidingDirection) {
                case SlidingDirectionUpOrDown:{
                    [self verticalStateEnded];
                    break;
                }
                case SlidingDirectionLeftOrRight:{
                    [self horizontalStateEndValue:locationPoint.x];
                    break;
                }
                    
                default:
                    break;
            }
        }
        default:
            break;
    }
}

另外,还有音量监听的方法如下:

#pragma mark - Notification

- (void)volumeChanged:(NSNotification *)notification {
    if ([notification.name isEqualToString:@"AVSystemController_SystemVolumeDidChangeNotification"]) {
        NSDictionary *userInfo = notification.userInfo;
        NSString *reasonString = userInfo[@"AVSystemController_AudioVolumeChangeReasonNotificationParameter"];
        if ([reasonString isEqualToString:@"ExplicitVolumeChange"]) {
            // 音量值,这里我们采用滑块调节的方式,所以这个属性可以不用到
//            CGFloat value = [userInfo[@"AVSystemController_AudioVolumeNotificationParameter"] doubleValue];
            [self verticalStateBeginIsVolume:YES];
            
            [self performSelector:@selector(hideContainerAnimation)
                       withObject:nil
                       afterDelay:HIDE_DELAY];
        }
    }
}

开发过程遇到的一些细节问题

1、如果与 UITableview 冲突,例如类似抖音首页,上下互动可以切换视频操作的界面。添加的 UIPanGestureRecognizer 使 UITableview 上下滑动冲突失效,那么需要在以下代理方法中做冲突处理:

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
    if (!_panHorizontalEnabled && 
        [otherGestureRecognizer.view isKindOfClass:[UITableView class]]) {
        return YES;
    }
    return NO;
}

2、如果是在类似播放器这种带有滑动条的情况下,为了避免对其影响,需要代理方法中进行判断(记得添加<UIGestureRecognizerDelegate>):

- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
       shouldReceiveTouch:(UITouch*)touch {
    if ([touch.view isKindOfClass:[UISlider class]]) {
        return NO;
    } else {
        return YES;
    }
}

3、手势滑动的视图(容器) justContainer 是覆盖到整个 self.view 的 fame,如果有需求,添加另外一个 view(例如叫 otherView) 需要覆盖到 self.view 整个 fame。
那么会有一下问题:
如果 justContainer 添加在 otherView 上,那么 justContainer 因为在整个 view 下面,所以无法响应用户手势;
同理,justContainer 添加在 otherView 下层,那么这个 view 上的添加是所有控件也无法响应用户手势。
解决办法:

// justContainer 先添加,otherView 则在上层。
[self addSubview:self.justContainer];
[self addSubview:self.otherView];

这一步很重要,把控制器的 self.view 传给 justContainer,命名为 parentView,记得用 weak 修饰,然后用 parentView 添加手势就可以解决啦。(详情可参见 demo~)

[self.parentView addGestureRecognizer:panGesture];

当然还有其他办法,例如把该有控件添加到 justContainer,不创建 otherView,例如控制用户的点击响应范围等等。
4、MPVolumeView 添加后,依然出现系统调节图案,检查一下看下是否 frame 没有设置,或者 hidden 设置成了 YES,当然也有另外一种可能,像我遇到使用公司 SDK 的播放器界面无论怎么添加,都出现系统调节图案,我怀疑是可能这个界面上对音量控制做了什么处理,所以我采用以下解决方案:就是把 mpVolumeView 添加在 window 上,而不是添加在这个界面。

[[UIApplication sharedApplication].keyWindow addSubview:self.mpVolumeView];



(转载请标明原文出处,谢谢支持 ~ - ~)
 by:啊左~

推荐阅读更多精彩内容