This repository has been archived by the owner on Apr 8, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 16
/
ABVolumeHUDContainerView.m
364 lines (308 loc) · 17.2 KB
/
ABVolumeHUDContainerView.m
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
//
// ABVolumeHUDContainerView.m
// Ultrasound
//
// Created by Ayden Panhuyzen on 8/27/18.
// Copyright © 2018 Ayden Panhuyzen. All rights reserved.
//
#import "ABVolumeHUDContainerView.h"
#import "ABVolumeHUDDeviceInfo.h"
#import "UIScreen+CornerRadius.h"
#import "ABVolumeHUDManager.h"
#import "ABVolumeHUDOLEDInfoView.h"
#import "ABInstantPanGestureRecognizer.h"
#import "ABVolumeHUDViewSettings.h"
#define kHUDDismissHorizontalOffset 28
@implementation ABVolumeHUDContainerView {
ABVolumeHUDView *volumeHUD;
ABVolumeHUDVisibilityManager *visibilityManager;
NSLayoutConstraint *edgeConstraint;
NSLayoutConstraint *edgeOLEDConstraint;
BOOL isAnimatingVisibility;
UIViewPropertyAnimator *animator;
BOOL _visible;
ABVolumeHUDDeviceInfo *deviceInfo;
UIView *mockVolumeSlider;
ABVolumeHUDOLEDInfoView *oledInfoView;
ABVolumeHUDVolumeModeInfo *volumeModeInfo;
ABInstantPanGestureRecognizer *interactiveDismissPan;
CGFloat interactiveStartPosition;
CGFloat interactiveTranslation;
BOOL hasYetToDecideTouchPurpose;
CGFloat previousSliderValue;
}
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
deviceInfo = [ABVolumeHUDDeviceInfo infoForCurrentDevice];
[self setupForDisplay];
previousSliderValue = -1;
}
return self;
}
- (void)setEffectiveOrientation:(UIInterfaceOrientation)effectiveOrientation {
_effectiveOrientation = effectiveOrientation;
[self adjustOrientation];
}
- (void)adjustOrientation {
UIInterfaceOrientation orientation = _effectiveOrientation;
[UIView performWithoutAnimation:^{
self->volumeHUD.containerView.transform = orientation == UIInterfaceOrientationPortraitUpsideDown || orientation == UIInterfaceOrientationLandscapeLeft ? CGAffineTransformMakeRotation(M_PI) : CGAffineTransformIdentity;
self->volumeHUD.topContainerView.transform = _oledMode ? CGAffineTransformIdentity : self->volumeHUD.containerView.transform;
BOOL isLandscape = UIInterfaceOrientationIsLandscape(orientation);
self->volumeHUD.sliderView.transform = isLandscape && !_oledMode ? CGAffineTransformMakeRotation(M_PI) : CGAffineTransformIdentity;
for (UIView *accessoryView in self->volumeHUD.accessoryViews) accessoryView.transform = isLandscape ? CGAffineTransformMakeRotation(M_PI_2) : CGAffineTransformIdentity;
self->volumeHUD.isInLandscapeMode = isLandscape;
}];
}
- (void)setupForDisplay {
visibilityManager = [[ABVolumeHUDVisibilityManager alloc] init];
visibilityManager.delegate = self;
// Make a hidden view that always is aligned to physical buttons, for referencing in constraints later
mockVolumeSlider = [[UIView alloc] init];
mockVolumeSlider.hidden = YES;
mockVolumeSlider.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:mockVolumeSlider];
[mockVolumeSlider.heightAnchor constraintEqualToConstant:deviceInfo.volumeButtonHeight].active = YES;
[mockVolumeSlider.widthAnchor constraintEqualToConstant:0].active = YES;
[mockVolumeSlider.topAnchor constraintEqualToAnchor:self.topAnchor constant:deviceInfo.volumeButtonTopOffset].active = YES;
[[self edgeAnchorForView:mockVolumeSlider] constraintEqualToAnchor:self.edgeAnchor].active = YES;
// Create volume HUD view itself
volumeHUD = [[ABVolumeHUDView alloc] init];
volumeHUD.translatesAutoresizingMaskIntoConstraints = NO;
volumeHUD.delegate = self;
volumeHUD.sliderView.viewToAnimateLayout = self;
volumeHUD.hidden = YES;
[self addSubview:volumeHUD];
interactiveDismissPan = [[ABInstantPanGestureRecognizer alloc] initWithTarget:self action:@selector(interactiveDismissPanned:)];
interactiveDismissPan.delaysTouchesBegan = NO;
interactiveDismissPan.delaysTouchesEnded = NO;
interactiveDismissPan.cancelsTouchesInView = NO;
interactiveDismissPan.delegate = self;
[volumeHUD addGestureRecognizer:interactiveDismissPan];
// Create view for additional info while in OLED mode
oledInfoView = [[ABVolumeHUDOLEDInfoView alloc] init];
oledInfoView.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:oledInfoView];
[[self edgeAnchorForView:oledInfoView] constraintEqualToAnchor:[self inverseEdgeAnchorForView:volumeHUD.sliderView] constant:12 * [self edgeConstantMultiplier]].active = YES;
[oledInfoView.topAnchor constraintGreaterThanOrEqualToAnchor:volumeHUD.sliderView.topAnchor].active = YES;
[oledInfoView.bottomAnchor constraintLessThanOrEqualToAnchor:volumeHUD.sliderView.bottomAnchor].active = YES;
NSLayoutConstraint *followKnobConstraint = [oledInfoView.centerYAnchor constraintEqualToAnchor:volumeHUD.sliderView.knobVerticalAnchor];
followKnobConstraint.priority = UILayoutPriorityDefaultLow;
followKnobConstraint.active = YES;
// Ensure that volume HUD doesn't go above top screen corner
[volumeHUD.topAnchor constraintGreaterThanOrEqualToAnchor:self.topAnchor constant:fmax(8, [UIScreen mainScreen]._displayCornerRadius)].active = YES;
// Create constraint to align slider with volume buttons from top
NSLayoutConstraint *volumeButtonTopConstraint = [volumeHUD.sliderView.centerYAnchor constraintEqualToAnchor:mockVolumeSlider.centerYAnchor];
volumeButtonTopConstraint.priority = UILayoutPriorityDefaultLow;
volumeButtonTopConstraint.active = YES;
// Make sure slider is same height as volume buttons
NSLayoutConstraint *volumeButtonSliderHeightAnchor = [volumeHUD.sliderView.heightAnchor constraintEqualToAnchor:mockVolumeSlider.heightAnchor];
volumeButtonSliderHeightAnchor.priority = UILayoutPriorityDefaultLow;
volumeButtonSliderHeightAnchor.active = YES;
// Constraint to pin volume HUD to edge of screen
edgeConstraint = [[self edgeAnchorForView:volumeHUD] constraintEqualToAnchor:self.edgeAnchor constant:8 * self.edgeConstantMultiplier];
edgeConstraint.active = YES;
// Constraint to pin volume HUD to edge of screen while in OLED mode
edgeOLEDConstraint = [[self edgeAnchorForView:volumeHUD.sliderView] constraintEqualToAnchor:self.edgeAnchor constant:2 * self.edgeConstantMultiplier];
// Set initial positioning and transformation
[self updateVolumeHUDVisible:_visible oledMode:_oledMode interactive:NO];
[self updatePositioningAnimated:NO];
[self layoutIfNeeded];
}
- (void)userInterfaceStyleChanged {
[volumeHUD applyThemeAnimated:YES];
}
- (void)interactiveDismissPanned:(ABInstantPanGestureRecognizer *)gesture {
CGFloat translation = [gesture translationInView:self].x;
CGPoint velocity = [gesture velocityInView:self];
if (gesture.state == UIGestureRecognizerStateBegan) {
// Stop if already running
if (animator.isRunning) {
[animator stopAnimation:NO];
}
[self createVisibilityAnimatorForTransitionToVisible:NO interactive:YES];
[self.visibilityManager prolongDisplayForReason:@"dismiss_interactive_touch"];
interactiveStartPosition = deviceInfo.rightAlignControls ? self.bounds.size.width - CGRectGetMinX(volumeHUD.frame) : CGRectGetMaxX(volumeHUD.frame);
// If the touch isn't inside the slider hit area, it's going to be a dismiss either way, so don't bother checking velocity later on. If it is inside, mark that we need to decide the touch's purpose
CGPoint sliderRelativeLocation = [gesture locationInView:volumeHUD.sliderView];
hasYetToDecideTouchPurpose = [volumeHUD.sliderView pointInside:sliderRelativeLocation withEvent:nil];
} else if (gesture.state == UIGestureRecognizerStateChanged) {
// Don't do anything without stopped animator
if (!animator || animator.isRunning) return;
// Cancel this pan if we're moving too far vertically
if (hasYetToDecideTouchPurpose) {
if (fabs(velocity.y) > 0.05 && fabs(velocity.x / velocity.y) <= 1) {
// Touch is likely moving vertically and trying to adjust volume, cancel dismiss pan
gesture.state = UIGestureRecognizerStateFailed;
return;
} else if (fabs(velocity.x) > 0.05 && fabs(velocity.y / velocity.x) <= 0.1) {
// Touch seems to be moving horizontally, stop checking touch direction like this
hasYetToDecideTouchPurpose = NO;
}
}
// Calculate and apply completion
CGFloat completionFraction = (translation + (deviceInfo.rightAlignControls ? 0 : interactiveStartPosition)) / interactiveStartPosition;
animator.fractionComplete = MAX(0.001, MIN(0.999, deviceInfo.rightAlignControls ? completionFraction : 1 - completionFraction));
} else if (gesture.state == UIGestureRecognizerStateEnded || gesture.state == UIGestureRecognizerStateFailed || gesture.state == UIGestureRecognizerStateCancelled) {
[self.visibilityManager releaseProlongedDisplayForReason:@"dismiss_interactive_touch"];
// Figure out whether to dismiss or not
CGFloat dismissPositionThreshold = interactiveStartPosition / 2;
BOOL willContinueDismiss = velocity.x * [self edgeConstantMultiplier] < -25 || -translation * [self edgeConstantMultiplier] > dismissPositionThreshold;
animator.reversed = !willContinueDismiss;
if (willContinueDismiss) {
// If we are dismissing, make sure we set to not visible after done
[animator addCompletion:^(UIViewAnimatingPosition finalPosition) {
if (finalPosition == UIViewAnimatingPositionEnd) {
self->_visible = NO;
}
}];
}
// Continue animation
UISpringTimingParameters *springParameters = [[UISpringTimingParameters alloc] initWithDampingRatio:0.9 initialVelocity:CGVectorMake(velocity.x / 100, velocity.y / 100)];
[animator continueAnimationWithTimingParameters:springParameters durationFactor:0.9];
}
}
- (void)volumeChangedTo:(CGFloat)volume withMode:(ABVolumeHUDVolumeMode)mode {
if (!volumeModeInfo || mode != volumeModeInfo.mode) volumeModeInfo = [ABVolumeHUDVolumeModeInfo infoForVolumeMode:mode];
[self layoutIfNeeded];
[volumeHUD volumeChangedTo:volume withModeInfo:volumeModeInfo];
[visibilityManager showVolumeHUD];
}
- (void)evaluateTapticFeedbackForVolume:(CGFloat)volume fromPreviousVolume:(CGFloat)previousVolume {
if (previousVolume < 0 || ![ABVolumeHUDViewSettings sharedSettings].enableHapticFeedback) return;
BOOL shouldPlayFeedback = (previousVolume > 0 && volume <= 0) || (previousVolume < 1 && volume >= 1);
if (!shouldPlayFeedback) return;
NSObject <ABVolumeHUDTapticFeedbackProviding>*tapticProvider = [ABVolumeHUDManager sharedManager].tapticFeedbackProvider;
if (!tapticProvider) return;
[tapticProvider actuate];
}
- (void)setOLEDMode:(BOOL)oledMode {
[self setOLEDMode:oledMode animated:YES];
}
- (void)setOLEDMode:(BOOL)oledMode animated:(BOOL)animated {
_oledMode = oledMode;
[self updateInteractivePanEnabled];
[self updateOLEDModeAnimated:animated];
[self adjustOrientation];
}
- (void)updateOLEDModeAnimated:(BOOL)animated {
if (isAnimatingVisibility) return;
[volumeHUD setOLEDMode:_oledMode animated:animated];
[self updatePositioningAnimated:animated];
[visibilityManager restartIdleTimer];
[self updateOLEDInfoViewAnimated:animated];
}
- (void)updatePositioningAnimated:(BOOL)animated {
edgeConstraint.active = !_oledMode;
edgeOLEDConstraint.active = _oledMode;
volumeHUD.maxVolumeSliderHeightConstraint.active = !_oledMode;
if (animated) {
[UIView animateWithDuration:0.2 delay:0 usingSpringWithDamping:1 initialSpringVelocity:1 options:UIViewAnimationOptionAllowUserInteraction | UIViewAnimationOptionBeginFromCurrentState animations:^{
[self layoutIfNeeded];
} completion:nil];
}
}
- (void)updateOLEDInfoViewAnimated:(BOOL)animated {
if (animated) {
[UIView animateWithDuration:0.2 delay:0 usingSpringWithDamping:1 initialSpringVelocity:1 options:UIViewAnimationOptionBeginFromCurrentState animations:^{
[self updateOLEDInfoViewAnimated:NO];
} completion:nil];
} else {
oledInfoView.alpha = _visible && _oledMode ? 1 : 0;
oledInfoView.transform = _oledMode || !_visible ? CGAffineTransformIdentity : CGAffineTransformMakeTranslation(kHUDDismissHorizontalOffset, 0);
}
}
- (void)createVisibilityAnimatorForTransitionToVisible:(BOOL)visible interactive:(BOOL)interactive {
UIViewPropertyAnimator *animator = [[UIViewPropertyAnimator alloc] initWithDuration:0.35 dampingRatio:0.9 animations:^{
[self updateVolumeHUDVisible:visible oledMode:self->_oledMode interactive:interactive];
[UIView animateWithDuration:0.35 delay:0 usingSpringWithDamping:1 initialSpringVelocity:1 options:UIViewAnimationOptionBeginFromCurrentState animations:^{
[self updateOLEDInfoViewAnimated:NO];
} completion:nil];
}];
[animator addCompletion:^(UIViewAnimatingPosition finalPosition) {
if (finalPosition == UIViewAnimatingPositionEnd && self->animator == animator) {
self->isAnimatingVisibility = NO;
self->volumeHUD.hidden = !visible;
[self updateOLEDModeAnimated:NO];
[self updateInteractivePanEnabled];
[[NSNotificationCenter defaultCenter] postNotificationName:kControlVisibilityChangedNotification object:nil userInfo:@{@"visible": @(visible)}];
}
}];
self->animator = animator;
}
- (void)updateInteractivePanEnabled {
interactiveDismissPan.enabled = _visible && !isAnimatingVisibility && !_oledMode;
}
- (void)shouldSwitchModes {
if (![ABVolumeHUDManager sharedManager].volumeInfoProvider) return;
// Get new desired mode
ABVolumeHUDVolumeMode newMode = volumeModeInfo.mode == ABVolumeHUDVolumeModeRinger ? ABVolumeHUDVolumeModeAudio : ABVolumeHUDVolumeModeRinger;
// Get new display volume
CGFloat newVolume = [[ABVolumeHUDManager sharedManager].volumeInfoProvider volumeForVolumeMode:newMode];
if (newVolume < 0) return;
[[ABVolumeHUDManager sharedManager] volumeChangedTo:newVolume withMode:newMode];
[[NSNotificationCenter defaultCenter] postNotificationName:kVolumeModeChangeNotification object:nil userInfo:@{@"mode": @(newMode)}];
}
// MARK: - Visibility Manager Delegate
- (void)shouldChangeVolumeHUDVisibleTo:(BOOL)visible {
if (visible == _visible) return;
_visible = visible;
if (!isAnimatingVisibility) {
[self updateVolumeHUDVisible:!visible oledMode:_oledMode];
volumeHUD.hidden = NO;
}
isAnimatingVisibility = YES;
[self createVisibilityAnimatorForTransitionToVisible:visible interactive:NO];
[animator startAnimation];
}
- (void)updateVolumeHUDVisible:(BOOL)visible oledMode:(BOOL)oledMode {
[self updateVolumeHUDVisible:visible oledMode:oledMode interactive:NO];
}
- (void)updateVolumeHUDVisible:(BOOL)visible oledMode:(BOOL)oledMode interactive:(BOOL)interactive {
self->volumeHUD.alpha = visible || oledMode || interactive ? 1 : 0;
if (visible) {
self->volumeHUD.transform = CGAffineTransformIdentity;
} else if (oledMode) {
self->volumeHUD.transform = CGAffineTransformMakeTranslation(-6 * self.edgeConstantMultiplier, 0);
} else {
self->volumeHUD.transform = interactive ? CGAffineTransformMakeTranslation(-interactiveStartPosition * self.edgeConstantMultiplier, 0) : CGAffineTransformTranslate(CGAffineTransformMakeScale(0.5, 0.5), -24 * self.edgeConstantMultiplier, 0);
}
}
// MARK: - Convenience for Edge Alignment
- (CGFloat)edgeConstantMultiplier {
return deviceInfo.rightAlignControls ? -1 : 1;
}
- (NSLayoutAnchor *)edgeAnchor {
return [self edgeAnchorForView:self];
}
- (NSLayoutAnchor *)edgeAnchorForView:(UIView *)view {
if (deviceInfo.rightAlignControls) return view.rightAnchor;
return view.leftAnchor;
}
- (NSLayoutAnchor *)inverseEdgeAnchorForView:(UIView *)view {
if (deviceInfo.rightAlignControls) return view.leftAnchor;
return view.rightAnchor;
}
// MARK: - Volume HUD Delegate
- (ABVolumeHUDVisibilityManager *)visibilityManager {
return visibilityManager;
}
- (void)sliderValueChangedTo:(CGFloat)value {
[oledInfoView volumeChangedTo:value withModeInfo:volumeModeInfo];
CGFloat _volume = previousSliderValue;
previousSliderValue = value;
[self evaluateTapticFeedbackForVolume:value fromPreviousVolume:_volume];
}
// MARK: - Hit Test
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
return [volumeHUD pointInside:[self convertPoint:point toView:volumeHUD] withEvent:event] || _oledMode;
}
// MARK - Gesture Recognizer Delegate
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
return gestureRecognizer == interactiveDismissPan && otherGestureRecognizer == volumeHUD.sliderView.panGestureRecognizer;
}
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
return gestureRecognizer == interactiveDismissPan && interactiveDismissPan.state != UIGestureRecognizerStateFailed && otherGestureRecognizer == volumeHUD.sliderView.panGestureRecognizer;
}
@end