blob: a834a70c5986a4938da62aeadf88aba35e024456 [file] [log] [blame] [edit]
// Copyright 2016-present the Material Components for iOS authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#import "MDCSnackbarOverlayView.h"
#import <Foundation/Foundation.h>
#import "../MDCSnackbarError.h"
#import "../MDCSnackbarMessage.h"
#import "MDCAvailability.h"
#import "MDCSnackbarAlignment.h"
#import "MDCSnackbarMessageView.h"
#import "MDCSnackbarMessageInternal.h"
#import "MDCSnackbarMessageViewInternal.h"
#import "UIApplication+MDCAppExtensions.h"
#import "MDCKeyboardWatcher.h"
#import "MDCOverlayImplementor.h"
NSString *const MDCSnackbarOverlayIdentifier = @"MDCSnackbar";
// The time it takes to show or hide the Snackbar.
NSTimeInterval const MDCSnackbarEnterTransitionDuration = 0.15;
NSTimeInterval const MDCSnackbarExitTransitionDuration = 0.125;
NSTimeInterval const MDCSnackbarLegacyTransitionDuration = 0.5;
// The scaling starting point for presenting the new Snackbar.
static const CGFloat MDCSnackbarEnterStartingScale = (CGFloat)0.8;
// The max ratio of the screen that a snackbar can occupy.
static const CGFloat MDCSnackbarMaxScreenRatioToOccupy = (CGFloat)0.5;
// How far from the top of the screen should the Snackbar be.
static const CGFloat MDCSnackbarTopMargin = 8;
// How far from the bottom of the screen should the Snackbar be.
static const CGFloat MDCSnackbarBottomMargin_iPhone = 8;
static const CGFloat MDCSnackbarBottomMargin_iPad = 24;
static const CGFloat MDCSnackbarLegacyBottomMargin_iPhone = 0;
static const CGFloat MDCSnackbarLegacyBottomMargin_iPad = 0;
// How far from the sides of the screen should the Snackbar be.
static const CGFloat MDCSnackbarSideMargin_CompactWidth = 8;
static const CGFloat MDCSnackbarLegacySideMargin_CompactWidth = 0;
static const CGFloat MDCSnackbarSideMargin_RegularWidth = 24;
// The maximum height of the legacy Snackbar.
static const CGFloat kMaximumHeightLegacy = 80;
#if MDC_AVAILABLE_SDK_IOS(10_0)
@interface MDCSnackbarOverlayView () <CAAnimationDelegate>
@end
#endif // MDC_AVAILABLE_SDK_IOS(10_0)
@interface MDCSnackbarOverlayView ()
/**
The Snackbar view to show. Setting this property simply puts the Snackbar view into the window
hierarchy and installs constraints which will keep it pinned to the bottom of the screen.
*/
@property(nonatomic) MDCSnackbarMessageView *snackbarView;
/**
The layout constraint which determines how far the Snackbar is from the leading edge of the screen.
It is active when the alignment of the parent overlay view is
MDCSnackbarHorizontalAlignmentLeading.
*/
@property(nonatomic) NSLayoutConstraint *snackbarViewLeadingConstraint;
/**
The layout constraint used to center the Snackbar.
It is active when the alignment of the parent overlay view is MDCSnackbarHorizontalAlignmentCenter.
*/
@property(nonatomic) NSLayoutConstraint *snackbarViewCenterConstraint;
/**
The layout constraint used to position the Snackbar.
It is active when the alignment of the parent overlay view is MDCSnackbarVerticalAlignmentTop.
*/
@property(nonatomic) NSLayoutConstraint *snackbarViewTopConstraint;
/**
The object which will notify us of changes in the keyboard position.
*/
@property(nonatomic) MDCKeyboardWatcher *watcher;
/**
The layout constraint which determines the bottom of the containing view. Setting the constant
to a negative value will cause Snackbars to appear from a point above the bottom of the screen.
*/
@property(nonatomic) NSLayoutConstraint *containingViewBottomConstraint;
/**
The layout constraint which determines the maximum height of the Snackbar .
*/
@property(nonatomic) NSLayoutConstraint *maximumHeightConstraint;
/**
The view which actually houses the Snackbar. This view is sized to be the same width and height as
ourselves, except offset from the bottom, based on the keyboard height as well as any user-set
content offsets.
*/
@property(nonatomic) UIView *containingView;
/**
Whether or not we are triggering a layout change ourselves. This is to distinguish when our bounds
are changing due to rotation rather than us adding/removing a Snackbar.
*/
@property(nonatomic) BOOL manualLayoutChange;
/**
If we received a rotation event, this is the duration that should be used.
*/
@property(nonatomic) NSTimeInterval rotationDuration;
/**
The constraint used to pin the bottom of the Snackbar to the bottom of the screen.
*/
@property(nonatomic) NSLayoutConstraint *snackbarBottomOnscreenConstraint;
/**
The constraint used to pin the top of the Snackbar to the bottom of the screen.
*/
@property(nonatomic, weak) NSLayoutConstraint *snackbarBottomOffscreenConstraint;
/**
The constraint used to set the leading margin spacing of the Snackbar.
*/
@property(nonatomic) NSLayoutConstraint *snackbarLeadingMarginConstraint;
/**
The constraint used to set the trailing margin spacing of the Snackbar.
*/
@property(nonatomic) NSLayoutConstraint *snackbarTrailingMarginConstraint;
@end
@implementation MDCSnackbarOverlayView
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
MDCKeyboardWatcher *watcher = [MDCKeyboardWatcher sharedKeyboardWatcher];
if (self) {
_watcher = watcher;
_containingView = [[UIView alloc] initWithFrame:frame];
_containingView.translatesAutoresizingMaskIntoConstraints = NO;
if (MDCSnackbarMessage.usesLegacySnackbar) {
_containingView.clipsToBounds = YES;
}
[self addSubview:_containingView];
// Set default side margin as leadingMargin and trailingMargin.
CGFloat sideMargin = self.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassRegular
? MDCSnackbarSideMargin_RegularWidth
: MDCSnackbarSideMargin_CompactWidth;
if (MDCSnackbarMessage.usesLegacySnackbar) {
sideMargin = self.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassRegular
? MDCSnackbarSideMargin_RegularWidth
: MDCSnackbarLegacySideMargin_CompactWidth;
}
_leadingMargin = _trailingMargin = sideMargin;
_topMargin = MDCSnackbarTopMargin;
NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
[nc addObserver:self
selector:@selector(keyboardWillShow:)
name:MDCKeyboardWatcherKeyboardWillShowNotification
object:watcher];
[nc addObserver:self
selector:@selector(keyboardWillBeHidden:)
name:MDCKeyboardWatcherKeyboardWillHideNotification
object:watcher];
[nc addObserver:self
selector:@selector(keyboardWillChangeFrame:)
name:MDCKeyboardWatcherKeyboardWillChangeFrameNotification
object:watcher];
#if !TARGET_OS_VISION
[nc addObserver:self
selector:@selector(willRotate:)
name:UIApplicationWillChangeStatusBarOrientationNotification
object:nil];
[nc addObserver:self
selector:@selector(didRotate:)
name:UIApplicationDidChangeStatusBarOrientationNotification
object:nil];
#endif
[self setupContainerConstraints];
}
return self;
}
/**
Installs constraints for the ever-present container view.
@note These constraints remain installed for the life of the overlay view, whereas the
constraints installed in @c setSnackbarView: come and go with the current Snackbar.
*/
- (void)setupContainerConstraints {
[self addConstraint:[NSLayoutConstraint constraintWithItem:_containingView
attribute:NSLayoutAttributeLeading
relatedBy:NSLayoutRelationEqual
toItem:self
attribute:NSLayoutAttributeLeading
multiplier:1.0
constant:0]];
[self addConstraint:[NSLayoutConstraint constraintWithItem:_containingView
attribute:NSLayoutAttributeTrailing
relatedBy:NSLayoutRelationEqual
toItem:self
attribute:NSLayoutAttributeTrailing
multiplier:1.0
constant:0]];
[self addConstraint:[NSLayoutConstraint constraintWithItem:_containingView
attribute:NSLayoutAttributeTop
relatedBy:NSLayoutRelationEqual
toItem:self.safeAreaLayoutGuide
attribute:NSLayoutAttributeTop
multiplier:1.0
constant:0]];
self.containingViewBottomConstraint =
[NSLayoutConstraint constraintWithItem:_containingView
attribute:NSLayoutAttributeBottom
relatedBy:NSLayoutRelationEqual
toItem:self
attribute:NSLayoutAttributeBottom
multiplier:1.0
constant:-[self dynamicBottomMargin]];
[self addConstraint:self.containingViewBottomConstraint];
}
- (void)updateConstraints {
[super updateConstraints];
self.maximumHeightConstraint.constant = self.maximumHeight;
CGFloat leftMargin = self.leadingMargin;
CGFloat rightMargin = self.trailingMargin;
BOOL isRegularWidth = self.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassRegular;
BOOL isRegularHeight = self.traitCollection.verticalSizeClass == UIUserInterfaceSizeClassRegular;
if (!isRegularWidth || !isRegularHeight) {
if (self.effectiveUserInterfaceLayoutDirection == UIUserInterfaceLayoutDirectionLeftToRight) {
leftMargin += self.mdc_safeAreaInsets.left;
rightMargin += self.mdc_safeAreaInsets.right;
} else {
leftMargin += self.mdc_safeAreaInsets.right;
rightMargin += self.mdc_safeAreaInsets.left;
}
_snackbarLeadingMarginConstraint.constant = leftMargin;
_snackbarTrailingMarginConstraint.constant = -1 * rightMargin;
}
}
/**
The bottom margin which is dependent on the keyboard and application-wide settings, and may
change at any time during runtime.
*/
- (CGFloat)dynamicBottomMargin {
CGFloat keyboardHeight = self.watcher.visibleKeyboardHeight;
CGFloat userHeight = self.bottomOffset;
if (!MDCSnackbarMessage.usesLegacySnackbar) {
userHeight = MAX(userHeight, self.safeAreaInsets.bottom);
}
return MAX(keyboardHeight, userHeight);
}
/**
The bottom margin which is dependent on device type and cannot change.
*/
- (CGFloat)staticBottomMargin {
if (MDCSnackbarMessage.usesLegacySnackbar) {
return UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad
? MDCSnackbarLegacyBottomMargin_iPad
: MDCSnackbarLegacyBottomMargin_iPhone;
}
return UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad
? MDCSnackbarBottomMargin_iPad
: MDCSnackbarBottomMargin_iPhone;
}
- (void)setSnackbarView:(MDCSnackbarMessageView *)snackbarView {
if (_snackbarView != snackbarView) {
[_snackbarView removeFromSuperview];
// Reset all constraints
self.snackbarViewLeadingConstraint = nil;
self.snackbarViewCenterConstraint = nil;
self.snackbarViewTopConstraint = nil;
self.snackbarBottomOnscreenConstraint = nil;
self.snackbarBottomOffscreenConstraint = nil;
self.snackbarLeadingMarginConstraint = nil;
self.snackbarTrailingMarginConstraint = nil;
self.maximumHeightConstraint = nil;
_snackbarView = snackbarView;
CGFloat bottomMargin = [self staticBottomMargin];
CGFloat leftMargin = self.leadingMargin;
CGFloat rightMargin = self.trailingMargin;
UIView *container = self.containingView;
if (snackbarView) {
[container addSubview:snackbarView];
// Pin the Snackbar to the bottom of the screen.
[snackbarView setTranslatesAutoresizingMaskIntoConstraints:NO];
BOOL isRegularWidth =
self.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassRegular;
BOOL isRegularHeight =
self.traitCollection.verticalSizeClass == UIUserInterfaceSizeClassRegular;
if (isRegularWidth && isRegularHeight) {
self.snackbarViewCenterConstraint =
[NSLayoutConstraint constraintWithItem:snackbarView
attribute:NSLayoutAttributeCenterX
relatedBy:NSLayoutRelationEqual
toItem:container
attribute:NSLayoutAttributeCenterX
multiplier:1.0
constant:0];
self.snackbarViewCenterConstraint.active =
self.horizontalAlignment == MDCSnackbarHorizontalAlignmentCenter;
self.snackbarViewLeadingConstraint =
[NSLayoutConstraint constraintWithItem:snackbarView
attribute:NSLayoutAttributeLeading
relatedBy:NSLayoutRelationEqual
toItem:container
attribute:NSLayoutAttributeLeading
multiplier:1.0
constant:self.leadingMargin];
self.snackbarViewLeadingConstraint.active =
self.horizontalAlignment == MDCSnackbarHorizontalAlignmentLeading;
// If not full width, ensure that it doesn't get any larger than our own width.
[container
addConstraint:[NSLayoutConstraint
constraintWithItem:snackbarView
attribute:NSLayoutAttributeWidth
relatedBy:NSLayoutRelationLessThanOrEqual
toItem:container
attribute:NSLayoutAttributeWidth
multiplier:1.0
constant:-(self.leadingMargin + self.trailingMargin)]];
// Also ensure that it doesn't get any smaller than its own minimum width.
[container
addConstraint:[NSLayoutConstraint constraintWithItem:snackbarView
attribute:NSLayoutAttributeWidth
relatedBy:NSLayoutRelationGreaterThanOrEqual
toItem:nil
attribute:NSLayoutAttributeNotAnAttribute
multiplier:1.0
constant:[snackbarView minimumWidth]]];
// Also ensure that it doesn't get any larger than its own maximum width.
[container
addConstraint:[NSLayoutConstraint constraintWithItem:snackbarView
attribute:NSLayoutAttributeWidth
relatedBy:NSLayoutRelationLessThanOrEqual
toItem:nil
attribute:NSLayoutAttributeNotAnAttribute
multiplier:1.0
constant:[snackbarView maximumWidth]]];
} else {
if (self.effectiveUserInterfaceLayoutDirection ==
UIUserInterfaceLayoutDirectionLeftToRight) {
leftMargin += self.mdc_safeAreaInsets.left;
rightMargin += self.mdc_safeAreaInsets.right;
} else {
leftMargin += self.mdc_safeAreaInsets.right;
rightMargin += self.mdc_safeAreaInsets.left;
}
_snackbarLeadingMarginConstraint =
[NSLayoutConstraint constraintWithItem:snackbarView
attribute:NSLayoutAttributeLeading
relatedBy:NSLayoutRelationEqual
toItem:container
attribute:NSLayoutAttributeLeading
multiplier:1.0
constant:leftMargin];
[container addConstraint:_snackbarLeadingMarginConstraint];
_snackbarTrailingMarginConstraint =
[NSLayoutConstraint constraintWithItem:snackbarView
attribute:NSLayoutAttributeTrailing
relatedBy:NSLayoutRelationEqual
toItem:container
attribute:NSLayoutAttributeTrailing
multiplier:1.0
constant:-1 * rightMargin];
[container addConstraint:_snackbarTrailingMarginConstraint];
}
_snackbarViewTopConstraint = [NSLayoutConstraint constraintWithItem:snackbarView
attribute:NSLayoutAttributeTop
relatedBy:NSLayoutRelationEqual
toItem:container
attribute:NSLayoutAttributeTop
multiplier:1.0
constant:self.topMargin];
_snackbarViewTopConstraint.active = self.verticalAlignment == MDCSnackbarVerticalAlignmentTop;
_snackbarBottomOnscreenConstraint =
[NSLayoutConstraint constraintWithItem:snackbarView
attribute:NSLayoutAttributeBottom
relatedBy:NSLayoutRelationEqual
toItem:container
attribute:NSLayoutAttributeBottom
multiplier:1.0
constant:-bottomMargin];
_snackbarBottomOnscreenConstraint.active =
!MDCSnackbarMessage.usesLegacySnackbar &&
self.verticalAlignment == MDCSnackbarVerticalAlignmentBottom;
if (MDCSnackbarMessage.usesLegacySnackbar) {
_snackbarBottomOnscreenConstraint.priority = UILayoutPriorityDefaultHigh;
}
_snackbarBottomOffscreenConstraint =
[NSLayoutConstraint constraintWithItem:snackbarView
attribute:NSLayoutAttributeTop
relatedBy:NSLayoutRelationEqual
toItem:container
attribute:NSLayoutAttributeBottom
multiplier:1.0
constant:-bottomMargin];
_snackbarBottomOffscreenConstraint.active =
MDCSnackbarMessage.usesLegacySnackbar &&
self.verticalAlignment == MDCSnackbarVerticalAlignmentBottom;
if (!MDCSnackbarMessage.usesLegacySnackbar) {
_snackbarBottomOffscreenConstraint.priority = UILayoutPriorityDefaultLow;
}
// Always limit the height of the Snackbar.
self.maximumHeightConstraint =
[NSLayoutConstraint constraintWithItem:snackbarView
attribute:NSLayoutAttributeHeight
relatedBy:NSLayoutRelationLessThanOrEqual
toItem:nil
attribute:NSLayoutAttributeNotAnAttribute
multiplier:1.0
constant:self.maximumHeight];
[container addConstraint:self.maximumHeightConstraint];
}
}
}
// All we care about is whether or not we tapped on the Snackbar view. Everything else should pass
// through to other windows. Only ask the Snackbar view if the given point belongs, and ignore all
// other touches.
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
BOOL result = NO;
if (self.snackbarView) {
CGPoint snackbarPoint = [self convertPoint:point toView:self.snackbarView];
result = [self.snackbarView pointInside:snackbarPoint withEvent:event];
}
return result;
}
- (void)triggerSnackbarLayoutChange {
self.manualLayoutChange = YES;
self.snackbarView.anchoredToScreenBottom = self.anchoredToScreenBottom;
[self layoutIfNeeded];
self.manualLayoutChange = NO;
}
- (CGRect)snackbarRectInScreenCoordinates {
#if !TARGET_OS_VISION
if (self.snackbarView == nil) {
return CGRectNull;
}
UIWindow *window = self.snackbarView.window;
if (window == nil) {
return CGRectNull;
}
return [self.snackbarView convertRect:self.snackbarView.bounds
toCoordinateSpace:window.screen.coordinateSpace];
#else
return CGRectNull;
#endif // TODO: b/359236816 - fix visionOS-specific compatibility workarounds.
}
- (CGFloat)maximumHeight {
CGFloat maximumHeight = kMaximumHeightLegacy;
if (self.anchoredToScreenBottom && MDCSnackbarMessage.usesLegacySnackbar) {
maximumHeight += self.safeAreaInsets.bottom;
}
if (!MDCSnackbarMessage.usesLegacySnackbar) {
CGFloat minimumHeight = self.snackbarView.minimumLayoutHeight;
// Calculate the maximum height based on the screen size and bottom margin (includes keyboard).
CGFloat windowBasedMaximumHeight = (self.window.frame.size.height - self.dynamicBottomMargin) *
MDCSnackbarMaxScreenRatioToOccupy;
// If there is no UIWindow object yet default to the maximumHeight.
return self.window ? MAX(windowBasedMaximumHeight, minimumHeight) : maximumHeight;
}
return maximumHeight;
}
- (BOOL)anchoredToScreenBottom {
return [self dynamicBottomMargin] == 0 &&
self.verticalAlignment == MDCSnackbarVerticalAlignmentBottom;
}
#pragma mark - Safe Area Insets
- (void)safeAreaInsetsDidChange {
[self setNeedsUpdateConstraints];
[self triggerSnackbarLayoutChange];
}
- (UIEdgeInsets)mdc_safeAreaInsets {
UIEdgeInsets insets = UIEdgeInsetsZero;
// Accommodate insets for iPhone X.
insets = self.safeAreaInsets;
return insets;
}
#pragma mark - Presentation/Dismissal
- (void)showSnackbarView:(MDCSnackbarMessageView *)snackbarView
animated:(BOOL)animated
completion:(void (^)(void))completion {
self.snackbarView = snackbarView; // Install the Snackbar.
self.containingViewBottomConstraint.constant = -self.dynamicBottomMargin;
if (animated && snackbarView) {
[self slideInMessageView:snackbarView completion:completion];
} else {
if (completion) {
completion();
}
}
}
- (void)dismissSnackbarViewAnimated:(BOOL)animated completion:(void (^)(void))completion {
if (animated && self.snackbarView) {
[self slideOutMessageView:self.snackbarView
completion:^{
self.snackbarView = nil; // Uninstall the Snackbar
if (completion) {
completion();
}
}];
} else {
self.snackbarView = nil;
if (completion) {
completion();
}
}
}
#pragma mark - Slide Animation
- (void)slideMessageView:(MDCSnackbarMessageView *)snackbarView
onscreen:(BOOL)onscreen
fromContentOpacity:(CGFloat)fromContentOpacity
toContentOpacity:(CGFloat)toContentOpacity
completion:(void (^)(void))completion {
// Prepare to move the Snackbar.
NSTimeInterval duration = MDCSnackbarLegacyTransitionDuration;
if (!MDCSnackbarMessage.usesLegacySnackbar) {
duration = onscreen ? MDCSnackbarEnterTransitionDuration : MDCSnackbarExitTransitionDuration;
}
CAMediaTimingFunction *timingFunction =
[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
[CATransaction begin];
[CATransaction setAnimationTimingFunction:timingFunction];
[CATransaction setCompletionBlock:completion];
[CATransaction setAnimationDuration:duration];
CAAnimationGroup *animationsGroup = [CAAnimationGroup animation];
animationsGroup.fillMode = kCAFillModeForwards;
animationsGroup.removedOnCompletion = NO;
if (MDCSnackbarMessage.usesLegacySnackbar) {
_snackbarBottomOnscreenConstraint.active = onscreen;
_snackbarBottomOffscreenConstraint.active = !onscreen;
[_containingView setNeedsUpdateConstraints];
// We use UIView animation inside a CATransaction in order to use the custom animation curve.
[UIView animateWithDuration:duration
delay:0
options:0
animations:^{
// Trigger Snackbar animation.
[self->_containingView layoutIfNeeded];
}
completion:nil];
[snackbarView animateContentOpacityFrom:fromContentOpacity
to:toContentOpacity
duration:duration
timingFunction:timingFunction];
} else {
NSMutableArray *animations = [[NSMutableArray alloc] init];
CABasicAnimation *opacityAnimation = [snackbarView animateSnackbarOpacityFrom:fromContentOpacity
to:toContentOpacity];
if (opacityAnimation) {
[animations addObject:opacityAnimation];
}
if (onscreen) {
CABasicAnimation *scaleAnimation =
[snackbarView animateSnackbarScaleFrom:MDCSnackbarEnterStartingScale toScale:1];
if (scaleAnimation) {
[animations addObject:scaleAnimation];
}
}
if (animations.count == 0) {
NSDictionary *errorDictionary = @{
@"MDCSnackbarMessageView" : snackbarView,
};
snackbarView.message.error =
[[NSError alloc] initWithDomain:MDCSnackbarErrorDomain
code:MDCSnackbarErrorSlideAnimationMisconfigured
userInfo:errorDictionary];
}
animationsGroup.animations = animations;
[snackbarView.layer addAnimation:animationsGroup forKey:@"snackbarAnimation"];
}
[CATransaction commit];
// To support the MDCOverlayObserver seeing frame changes, we need to update the frame of the
// new Snackbar for the observer, as now it doesn't change frame but rather change opacity.
// In future we should add support for opacity to our MDCOverlayObserver and not only frame.
CGRect snackbarRect = [self snackbarRectInScreenCoordinates];
if (!MDCSnackbarMessage.usesLegacySnackbar && !onscreen) {
snackbarRect.origin.y = self.bounds.size.height - [self dynamicBottomMargin];
}
// Notify the overlay system.
[self notifyOverlayChangeWithFrame:snackbarRect
duration:duration
curve:0
timingFunction:timingFunction];
}
- (void)slideInMessageView:(MDCSnackbarMessageView *)snackbarView
completion:(void (^)(void))completion {
// Make sure that the Snackbar has been properly sized to calculate the translation value.
[self triggerSnackbarLayoutChange];
[self slideMessageView:snackbarView
onscreen:YES
fromContentOpacity:0
toContentOpacity:1
completion:completion];
}
- (void)slideOutMessageView:(MDCSnackbarMessageView *)snackbarView
completion:(void (^)(void))completion {
// Make sure that the Snackbar has been properly sized to calculate the translation value.
[self triggerSnackbarLayoutChange];
[self slideMessageView:snackbarView
onscreen:NO
fromContentOpacity:1
toContentOpacity:0
completion:completion];
}
#pragma mark - Keyboard Notifications
- (void)updatesnackbarPositionWithKeyboardUserInfo:(NSDictionary *)userInfo {
// Always set the bottom constraint, even if there isn't a Snackbar currently displayed.
void (^updateBlock)(void) = ^{
self.containingViewBottomConstraint.constant = -[self dynamicBottomMargin];
self.maximumHeightConstraint.constant = self.maximumHeight;
[self triggerSnackbarLayoutChange];
};
if (self.snackbarView) {
NSTimeInterval duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] floatValue];
UIViewAnimationCurve curve =
[userInfo[UIKeyboardAnimationCurveUserInfoKey] unsignedIntegerValue];
UIViewAnimationOptions options = UIViewAnimationOptionBeginFromCurrentState | curve << 16;
[UIView animateWithDuration:duration
delay:0
options:options
animations:updateBlock
completion:nil];
// Notify the overlay system that a change is happening.
[self notifyOverlayChangeWithFrame:[self snackbarRectInScreenCoordinates]
duration:duration
curve:curve
timingFunction:nil];
} else {
updateBlock();
}
}
- (void)keyboardWillShow:(NSNotification *)notification {
[self updatesnackbarPositionWithKeyboardUserInfo:[notification userInfo]];
}
- (void)keyboardWillBeHidden:(NSNotification *)notification {
[self updatesnackbarPositionWithKeyboardUserInfo:[notification userInfo]];
}
- (void)keyboardWillChangeFrame:(NSNotification *)notification {
[self updatesnackbarPositionWithKeyboardUserInfo:[notification userInfo]];
}
#pragma mark - Bottom And Side Margins
- (void)setBottomOffset:(CGFloat)bottomOffset {
if (_bottomOffset != bottomOffset) {
_bottomOffset = bottomOffset;
self.maximumHeightConstraint.constant = self.maximumHeight;
self.containingViewBottomConstraint.constant = -self.dynamicBottomMargin;
[self triggerSnackbarLayoutChange];
// If there is no Snackbar the following method returns CGRectNull, but we still need to notify
// observers of bottom offset changes.
CGRect frame = [self snackbarRectInScreenCoordinates];
if (CGRectIsNull(frame)) {
frame = CGRectMake(0, CGRectGetHeight(self.frame) - self.bottomOffset,
CGRectGetWidth(self.frame), self.bottomOffset);
}
[self notifyOverlayChangeWithFrame:frame
duration:[CATransaction animationDuration]
curve:UIViewAnimationCurveEaseInOut
timingFunction:nil];
}
}
- (void)setHorizontalAlignment:(MDCSnackbarHorizontalAlignment)horizontalAlignment {
if (_horizontalAlignment != horizontalAlignment) {
_horizontalAlignment = horizontalAlignment;
[self activateSnackbarViewConstraintsForHorizontalAlignment:horizontalAlignment
verticalAlignment:self.verticalAlignment];
[self triggerSnackbarLayoutChange];
// If there is no Snackbar the following method returns CGRectNull, but we still need to notify
// observers of bottom offset changes.
CGRect frame = [self snackbarRectInScreenCoordinates];
if (CGRectIsNull(frame)) {
frame = CGRectMake(0, CGRectGetHeight(self.frame) - self.bottomOffset,
CGRectGetWidth(self.frame), self.bottomOffset);
}
[self notifyOverlayChangeWithFrame:frame
duration:[CATransaction animationDuration]
curve:UIViewAnimationCurveEaseInOut
timingFunction:nil];
}
}
- (void)setVerticalAlignment:(MDCSnackbarVerticalAlignment)verticalAlignment {
if (_verticalAlignment != verticalAlignment) {
_verticalAlignment = verticalAlignment;
[self activateSnackbarViewConstraintsForHorizontalAlignment:self.horizontalAlignment
verticalAlignment:verticalAlignment];
[self triggerSnackbarLayoutChange];
CGRect frame = [self snackbarRectInScreenCoordinates];
if (!CGRectIsNull(frame)) {
[self notifyOverlayChangeWithFrame:frame
duration:[CATransaction animationDuration]
curve:UIViewAnimationCurveEaseInOut
timingFunction:nil];
}
}
}
- (void)activateSnackbarViewConstraintsForHorizontalAlignment:
(MDCSnackbarHorizontalAlignment)horizontalAlignment
verticalAlignment:
(MDCSnackbarVerticalAlignment)verticalAlignment {
switch (horizontalAlignment) {
case MDCSnackbarHorizontalAlignmentCenter:
self.snackbarViewLeadingConstraint.active = NO;
self.snackbarViewCenterConstraint.active = YES;
break;
case MDCSnackbarHorizontalAlignmentLeading:
self.snackbarViewLeadingConstraint.active = YES;
self.snackbarViewCenterConstraint.active = NO;
break;
default:
self.snackbarViewLeadingConstraint.active = NO;
self.snackbarViewCenterConstraint.active = YES;
break;
}
switch (verticalAlignment) {
case MDCSnackbarVerticalAlignmentBottom:
self.snackbarViewTopConstraint.active = NO;
self.snackbarBottomOnscreenConstraint.active = !MDCSnackbarMessage.usesLegacySnackbar;
self.snackbarBottomOffscreenConstraint.active = MDCSnackbarMessage.usesLegacySnackbar;
break;
case MDCSnackbarVerticalAlignmentTop:
self.snackbarViewTopConstraint.active = YES;
self.snackbarBottomOnscreenConstraint.active = NO;
self.snackbarBottomOffscreenConstraint.active = NO;
break;
default:
self.snackbarViewTopConstraint.active = NO;
self.snackbarBottomOnscreenConstraint.active = !MDCSnackbarMessage.usesLegacySnackbar;
self.snackbarBottomOffscreenConstraint.active = MDCSnackbarMessage.usesLegacySnackbar;
break;
}
}
#pragma mark - Rotation
- (void)handleRotation {
if (self.snackbarView != nil) {
[self notifyOverlayChangeWithFrame:[self snackbarRectInScreenCoordinates]
duration:self.rotationDuration
curve:UIViewAnimationCurveEaseInOut
timingFunction:nil];
}
}
- (void)layoutSubviews {
[super layoutSubviews];
if (!self.manualLayoutChange && self.rotationDuration > 0) {
[self.containingView layoutIfNeeded];
[self handleRotation];
}
}
- (void)willRotate:(NSNotification *)notification {
#if !TARGET_OS_VISION
UIApplication *application = [UIApplication mdc_safeSharedApplication];
UIInterfaceOrientation currentOrientation = application.statusBarOrientation;
UIInterfaceOrientation targetOrientation =
[notification.userInfo[UIApplicationStatusBarOrientationUserInfoKey] integerValue];
NSTimeInterval duration = application.statusBarOrientationAnimationDuration;
// If this is a landscape->landscape or portrait->portrait rotation, then double the duration.
BOOL currentIsLandscape = UIInterfaceOrientationIsLandscape(currentOrientation);
BOOL targetIsLandscape = UIInterfaceOrientationIsLandscape(targetOrientation);
if (currentIsLandscape == targetIsLandscape) {
duration = 2 * duration;
}
self.rotationDuration = duration;
#endif // TODO: b/359220619 - fix this workaround for visionOS incompatibility.
}
- (void)didRotate:(__unused NSNotification *)notification {
// The UIApplicationDidChangeStatusBarOrientationNotification happens pretty much immediately
// after the willRotate notification, before any layouts are changed. By delaying this until the
// next runloop, any rotation-related layout changes will occur, and we can know that they were
// due to rotation.
dispatch_async(dispatch_get_main_queue(), ^{
self.rotationDuration = -1;
});
}
#pragma mark - Overlay Support
- (void)notifyOverlayChangeWithFrame:(CGRect)frame
duration:(NSTimeInterval)duration
curve:(UIViewAnimationCurve)curve
timingFunction:(CAMediaTimingFunction *)timingFunction {
NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:@{
MDCOverlayIdentifierKey : MDCSnackbarOverlayIdentifier,
MDCOverlayFrameKey : [NSValue valueWithCGRect:frame],
MDCOverlayTransitionDurationKey : @(duration),
}];
if (duration > 0) {
if (timingFunction != nil) {
userInfo[MDCOverlayTransitionTimingFunctionKey] = timingFunction;
} else {
userInfo[MDCOverlayTransitionCurveKey] = @(curve);
}
}
// Notify the overlay system that a change is happening
[[NSNotificationCenter defaultCenter] postNotificationName:MDCOverlayDidChangeNotification
object:nil
userInfo:userInfo];
}
#pragma mark - UIAccessibilityAction
- (BOOL)accessibilityPerformEscape {
if (self.snackbarView) {
[self.snackbarView dismissWithAction:nil userInitiated:YES];
return YES;
} else {
return NO;
}
}
@end