| // Copyright 2015-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 "MDCFlexibleHeaderView.h" |
| |
| #import "private/MDCFlexibleHeaderMinMaxHeight.h" |
| #import "private/MDCFlexibleHeaderShifter.h" |
| #import "private/MDCFlexibleHeaderTopSafeArea.h" |
| #import "private/MDCFlexibleHeaderView+Private.h" |
| #import "private/MDCStatusBarShifter.h" |
| #import "MaterialElevation.h" |
| #import "MDCFlexibleHeaderView+ShiftBehavior.h" |
| #import "MDCFlexibleHeaderViewAnimationDelegate.h" |
| #import "MDCFlexibleHeaderViewDelegate.h" |
| #import "MaterialFlexibleHeader+ShiftBehavior.h" |
| #import "MaterialFlexibleHeader+ShiftBehaviorEnabledWithStatusBar.h" |
| #import "MDCFlexibleHeaderMinMaxHeightDelegate.h" |
| #import "MDCFlexibleHeaderTopSafeAreaDelegate.h" |
| #import "MDCStatusBarShifterDelegate.h" |
| #import "MaterialShadowElevations.h" |
| #import "MaterialApplication.h" |
| #import "MaterialMath.h" |
| #import "MaterialUIMetrics.h" |
| |
| #if TARGET_IPHONE_SIMULATOR |
| float UIAnimationDragCoefficient(void); // Private API for simulator animation speed |
| #endif |
| |
| const MDCFlexibleHeaderShiftBehavior MDCFlexibleHeaderShiftBehaviorDisabled = 0; |
| const MDCFlexibleHeaderShiftBehavior MDCFlexibleHeaderShiftBehaviorEnabled = 1; |
| const MDCFlexibleHeaderShiftBehavior MDCFlexibleHeaderShiftBehaviorEnabledWithStatusBar = 2; |
| const MDCFlexibleHeaderShiftBehavior MDCFlexibleHeaderShiftBehaviorHideable = 3; |
| |
| // The maximum default opacity of the shadow. |
| static const float kDefaultVisibleShadowOpacity = (float)0.4; |
| |
| // The percentage shifted threshold at which point the _viewsToHideWhenShifted should be fully |
| // hidden. |
| static const float kContentHidingThreshold = (float)0.5; |
| |
| // This length defines the moment at which the shadow will be fully visible as the header shifts |
| // on-screen. |
| static const CGFloat kShadowScaleLength = 8; |
| |
| // Duration of the UIKit animation that occurs when changing the tracking scroll view. |
| static const NSTimeInterval kTrackingScrollViewDidChangeAnimationDuration = 0.2; |
| |
| // The epsilon used to determine when we've arrived at the destination while shifting the header |
| // on/off-screen with the display link. |
| static const float kShiftEpsilon = (float)0.1; |
| |
| // The epsilon used when comparing height values. |
| static const CGFloat kHeightEpsilon = (CGFloat)0.001; |
| |
| // The epsilon used when comparing content offset values. |
| static const CGFloat kContentOffsetEpsilon = (CGFloat)0.001; |
| |
| // The minimum delta y before we change the scroll direction. |
| static const CGFloat kDeltaYSlop = 5; |
| |
| // Affects how fast the header shifts on/off-screen while animating. Bigger value = faster. |
| static const CGFloat kAttachmentCoefficient = 12; |
| |
| // The amount the user needs to scroll back before the header starts shifting back on-screen. |
| static const CGFloat kMaxAnchorLengthFullSwipe = 175; |
| static const CGFloat kMaxAnchorLengthQuickSwipe = 25; |
| |
| // The minimum proportion of the header that will cause it to slide back on screen when the scroll |
| // view finishes decelerating with the header partially shifted. |
| static const CGFloat kMinimumVisibleProportion = 0.25; |
| |
| // KVO contexts |
| static char *const kKVOContextMDCFlexibleHeaderView = "kKVOContextMDCFlexibleHeaderView"; |
| |
| @interface MDCFlexibleHeaderView () <MDCStatusBarShifterDelegate, |
| MDCFlexibleHeaderTopSafeAreaDelegate, |
| MDCFlexibleHeaderMinMaxHeightDelegate> |
| |
| // The intensity strength of the shadow being displayed under the flexible header. Use this property |
| // to check what the intensity of a custom shadow should be depending on a scroll position. Valid |
| // values range from 0 to 1. Where 0 is no shadow is visible and 1 is the shadow is fully visible. |
| @property(nonatomic, readonly) CGFloat shadowIntensity; |
| |
| // Exposed via the FlexibleHeader+CanAlwaysExpandToMaximumHeight target. |
| @property(nonatomic) BOOL canAlwaysExpandToMaximumHeight; |
| |
| // Extracted logic units |
| @property(nonatomic, strong) MDCFlexibleHeaderTopSafeArea *topSafeArea; |
| @property(nonatomic, strong) MDCFlexibleHeaderMinMaxHeight *minMaxHeight; |
| |
| // To be deprecated APIs; re-declared here in order to be auto-synthesized. |
| @property(nonatomic) BOOL minMaxHeightIncludesSafeArea; |
| @property(nonatomic) BOOL resetShadowAfterTrackingScrollViewIsReset; |
| @property(nonatomic, assign) BOOL allowShadowLayerFrameAnimationsWhenChangingTrackingScrollView; |
| |
| @end |
| |
| // All injections into the content and scroll indicator insets are tracked here. It's super |
| // important that we track what we added, rather than trying to cache the original values, because |
| // we can't know if the insets have changed out from under us by another party. |
| // |
| // A separate info object is tracked for each scroll view tracked by the flexible header view. |
| @interface MDCFlexibleHeaderScrollViewInfo : NSObject |
| |
| // UITableView, when added to a UIWindow for the first time, may automatically adjust |
| // its content offset to take into account the top safe area insets. While typically |
| // desirable, this behavior clashes with our own top safe area insets management resulting |
| // in the table view "jumping" when it first appears. To counter this behavior, we |
| // intentionally ignore the next content offset change if it looks like a safe area adjustment. |
| // See https://github.com/material-components/material-components-ios/issues/5412 for additional |
| // details. |
| @property(nonatomic) BOOL shouldIgnoreNextSafeAreaAdjustment; |
| |
| // The amount injected into contentInsets.top |
| @property(nonatomic) CGFloat injectedTopContentInset; |
| |
| // Whether or not we've injected the top content inset |
| @property(nonatomic) BOOL hasInjectedTopContentInset; |
| |
| // The amount injected into scrollIndicatorInsets.top |
| @property(nonatomic) CGFloat injectedTopScrollIndicatorInset; |
| |
| // When working with multiple tracking scroll views, this property keeps track of what the last |
| // header height for a given tracking scroll view was. When we return to a tracking scroll view, |
| // we're able to use this property to calculate any additional content offset shift that may be |
| // required in order to maintain a consistent physical placement within the content. |
| @property(nonatomic) CGFloat stashedHeight; |
| @property(nonatomic) BOOL stashedHeightIsValid; |
| |
| @end |
| |
| @implementation MDCFlexibleHeaderView { |
| // We keep a weak reference to all forwarding views in case the client forgets to stop forwarding |
| // events for a view that's been removed from the header view. If we held a strong reference here |
| // then the removed view would never be deallocated. |
| NSHashTable *_forwardingViews; // [UIView] |
| |
| // Views that should be hidden during shifting. These views are kept as weak references. |
| NSHashTable *_viewsToHideWhenShifted; // [UIView] |
| |
| // A weak reference map of scroll views to info that have been tracked by this header view. |
| NSMapTable *_trackedScrollViews; // {UIScrollView:MDCFlexibleHeaderScrollViewInfo} |
| MDCFlexibleHeaderScrollViewInfo *_trackingInfo; |
| |
| // The ideal visibility state of the header. This may not match the present visibility if the user |
| // is interacting with the header or if we're presently animating it. |
| BOOL _wantsToBeHidden; |
| |
| // Shift behavior state |
| |
| // Prevents delta calculations on first update pass. |
| BOOL _shiftAccumulatorLastContentOffsetIsValid; |
| // When the header can slide off-screen, a positive value indicates how off-screen the header is. |
| // Essentially: view's top edge = -_shiftAccumulator |
| // When canAlwaysExpandToMaximumHeight is enabled, a negative value indicates how expanded the |
| // header is. |
| // Essentially: view's height += -_shiftAccumulator |
| CGFloat _shiftAccumulator; |
| CGPoint _shiftAccumulatorLastContentOffset; // Stores our last delta'd content offset. |
| CGFloat _shiftAccumulatorDeltaY; |
| CADisplayLink *_shiftAccumulatorDisplayLink; |
| |
| BOOL _interfaceOrientationIsChanging; |
| BOOL _contentInsetsAreChanging; |
| |
| // When the user tosses the scroll view we enter a deceleration phase. This is always eventually |
| // followed up by a call to trackingScrollViewDidEndDecelerating. See the |
| // trackingScrollViewDidEndDecelerating for more details on this behavior. |
| BOOL _didDecelerate; |
| |
| // _isChangingStatusBarVisibility documents whether we know that we're adjusting the status bar |
| // visibility, while _wasStatusBarHidden allows us to detect whether someone else has adjusted |
| // the status bar visibility. In either case, we need to counteract any content offsets |
| // adjustments made by UIKit so that our header doesn't shrink/expand in reaction to the status |
| // bar visibility changing. |
| BOOL _isChangingStatusBarVisibility; |
| BOOL _wasStatusBarHiddenIsValid; |
| BOOL _wasStatusBarHidden; |
| |
| // UILayoutGuide was introduced in iOS 9, so in order to support iOS 8, we use a UIView as a |
| // layout guide. Once we drop iOS 8 support this can be changed to a UILayoutGuide instead. |
| UIView *_topSafeAreaGuide; |
| |
| MDCFlexibleHeaderShifter *_shifter; |
| MDCStatusBarShifter *_statusBarShifter; |
| |
| // Layers for header shadows. |
| CALayer *_defaultShadowLayer; |
| CALayer *_customShadowLayer; |
| |
| // The block executed when shadow intensity changes. |
| MDCFlexibleHeaderShadowIntensityChangeBlock _shadowIntensityChangeBlock; |
| |
| // Whether the flexible header is currently within an animate block for changing the tracking |
| // scroll view. |
| BOOL _isAnimatingLayoutUpdate; |
| |
| Class _wkWebViewClass; |
| |
| #if DEBUG |
| // Keeps track of whether the client called ...WillEndDraggingWithVelocity:... |
| BOOL _didAdjustTargetContentOffset; |
| #endif |
| } |
| |
| // Owned by _topSafeArea |
| @dynamic topSafeAreaSourceViewController; |
| @dynamic inferTopSafeAreaInsetFromViewController; |
| |
| // Owned by self.minMaxHeight |
| @dynamic minimumHeight; |
| @dynamic maximumHeight; |
| @dynamic minMaxHeightIncludesSafeArea; |
| |
| // MDCFlexibleHeader properties |
| @synthesize mdc_overrideBaseElevation = _mdc_overrideBaseElevation; |
| @synthesize mdc_elevationDidChangeBlock = _mdc_elevationDidChangeBlock; |
| @synthesize trackingScrollViewIsBeingScrubbed = _trackingScrollViewIsBeingScrubbed; |
| @synthesize scrollPhase = _scrollPhase; |
| @synthesize scrollPhaseValue = _scrollPhaseValue; |
| @synthesize scrollPhasePercentage = _scrollPhasePercentage; |
| |
| // MDCFlexibleHeaderConfiguration properties |
| @synthesize trackingScrollView = _trackingScrollView; |
| |
| @synthesize canOverExtend = _canOverExtend; |
| @synthesize inFrontOfInfiniteContent = _inFrontOfInfiniteContent; |
| @synthesize sharedWithManyScrollViews = _sharedWithManyScrollViews; |
| @synthesize visibleShadowOpacity = _visibleShadowOpacity; |
| |
| - (void)dealloc { |
| #if DEBUG |
| [_trackingScrollView.panGestureRecognizer removeTarget:self |
| action:@selector(fhv_scrollViewDidPan:)]; |
| #endif |
| [[NSNotificationCenter defaultCenter] removeObserver:self]; |
| if (self.observesTrackingScrollViewScrollEvents) { |
| [self fhv_stopObservingContentOffset]; |
| } |
| } |
| |
| - (instancetype)initWithFrame:(CGRect)frame { |
| self = [super initWithFrame:frame]; |
| if (self) { |
| [self commonMDCFlexibleHeaderViewInit]; |
| } |
| return self; |
| } |
| |
| - (instancetype)initWithCoder:(NSCoder *)aDecoder { |
| self = [super initWithCoder:aDecoder]; |
| if (self) { |
| [self commonMDCFlexibleHeaderViewInit]; |
| } |
| return self; |
| } |
| |
| - (void)commonMDCFlexibleHeaderViewInit { |
| _topSafeArea = [[MDCFlexibleHeaderTopSafeArea alloc] init]; |
| _topSafeArea.topSafeAreaDelegate = self; |
| |
| _minMaxHeight = [[MDCFlexibleHeaderMinMaxHeight alloc] initWithTopSafeArea:_topSafeArea]; |
| _minMaxHeight.delegate = self; |
| |
| _wkWebViewClass = NSClassFromString(@"WKWebView"); |
| |
| _shifter = [[MDCFlexibleHeaderShifter alloc] init]; |
| _statusBarShifter = [[MDCStatusBarShifter alloc] init]; |
| _statusBarShifter.delegate = self; |
| _statusBarShifter.enabled = [self fhv_shouldAllowShifting]; |
| |
| NSPointerFunctionsOptions options = |
| (NSPointerFunctionsWeakMemory | NSPointerFunctionsObjectPointerPersonality); |
| _forwardingViews = [NSHashTable hashTableWithOptions:options]; |
| _viewsToHideWhenShifted = [NSHashTable hashTableWithOptions:options]; |
| |
| NSPointerFunctionsOptions keyOptions = |
| (NSPointerFunctionsWeakMemory | NSPointerFunctionsObjectPointerPersonality); |
| NSPointerFunctionsOptions valueOptions = |
| (NSPointerFunctionsStrongMemory | NSPointerFunctionsObjectPointerPersonality); |
| _trackedScrollViews = [NSMapTable mapTableWithKeyOptions:keyOptions valueOptions:valueOptions]; |
| |
| _headerContentImportance = MDCFlexibleHeaderContentImportanceDefault; |
| _statusBarHintCanOverlapHeader = YES; |
| |
| _visibleShadowOpacity = kDefaultVisibleShadowOpacity; |
| _canOverExtend = YES; |
| |
| _defaultShadowLayer = [CALayer layer]; |
| _defaultShadowLayer.shadowColor = [[UIColor blackColor] CGColor]; |
| _defaultShadowLayer.shadowOffset = CGSizeMake(0, 1); |
| _defaultShadowLayer.shadowRadius = 4; |
| _defaultShadowLayer.shadowOpacity = 0; |
| _defaultShadowLayer.hidden = YES; |
| [self.layer addSublayer:_defaultShadowLayer]; |
| |
| // Allow for custom shadows to be used. |
| _customShadowLayer = [CALayer layer]; |
| _customShadowLayer.hidden = YES; |
| [self.layer addSublayer:_customShadowLayer]; |
| |
| _shadowColor = UIColor.blackColor; |
| |
| _topSafeAreaGuide = [[UIView alloc] init]; |
| _topSafeAreaGuide.frame = CGRectMake(0, 0, 0, [_topSafeArea topSafeAreaInset]); |
| [self addSubview:_topSafeAreaGuide]; |
| |
| _contentView = [[UIView alloc] initWithFrame:self.bounds]; |
| _contentView.autoresizingMask = |
| (UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight); |
| [super addSubview:_contentView]; |
| |
| if (![MDCFlexibleHeaderView appearance].backgroundColor) { |
| self.backgroundColor = [UIColor lightGrayColor]; |
| } |
| |
| _defaultShadowLayer.backgroundColor = self.backgroundColor.CGColor; |
| |
| self.layer.shadowColor = [[UIColor blackColor] CGColor]; |
| self.layer.shadowOffset = CGSizeMake(0, 1); |
| self.layer.shadowRadius = 4; |
| self.layer.shadowOpacity = 0; |
| |
| self.minimumHeaderViewHeight = 0.0; |
| |
| NSString *voiceOverNotification; |
| if (@available(iOS 11.0, *)) { |
| voiceOverNotification = UIAccessibilityVoiceOverStatusDidChangeNotification; |
| } else { |
| voiceOverNotification = UIAccessibilityVoiceOverStatusChanged; |
| } |
| |
| [[NSNotificationCenter defaultCenter] addObserver:self |
| selector:@selector(fhv_updateLayout) |
| name:voiceOverNotification |
| object:nil]; |
| |
| _mdc_overrideBaseElevation = -1; |
| } |
| |
| - (void)setVisibleShadowOpacity:(float)visibleShadowOpacity { |
| _visibleShadowOpacity = visibleShadowOpacity; |
| [self fhv_accumulatorDidChange]; |
| } |
| |
| - (void)fhv_setShadowLayer:(CALayer *)shadowLayer |
| intensityDidChangeBlock:(MDCFlexibleHeaderShadowIntensityChangeBlock)block { |
| _shadowIntensityChangeBlock = block; |
| |
| // If there is a custom shadow make sure the shadow on self.layer is not visible. |
| self.layer.shadowOpacity = 0; |
| CALayer *oldShadowLayer = _shadowLayer; |
| if (shadowLayer == _shadowLayer) { |
| return; |
| } |
| _shadowLayer = shadowLayer; |
| [oldShadowLayer removeFromSuperlayer]; |
| if (shadowLayer) { |
| // When a custom shadow is being used hide the default shadow. |
| _defaultShadowLayer.hidden = YES; |
| _customShadowLayer.hidden = NO; |
| [_customShadowLayer addSublayer:_shadowLayer]; |
| } else { |
| _defaultShadowLayer.hidden = NO; |
| _customShadowLayer.hidden = YES; |
| _shadowLayer = nil; |
| } |
| } |
| |
| - (void)setShadowLayer:(CALayer *)shadowLayer { |
| [self fhv_setShadowLayer:shadowLayer intensityDidChangeBlock:nil]; |
| } |
| |
| - (void)setShadowLayer:(CALayer *)shadowLayer |
| intensityDidChangeBlock:(MDCFlexibleHeaderShadowIntensityChangeBlock)block { |
| [self fhv_setShadowLayer:shadowLayer intensityDidChangeBlock:block]; |
| } |
| |
| - (void)setShadowColor:(UIColor *)shadowColor { |
| _shadowColor = [shadowColor copy]; |
| [self fhv_updateShadowColor]; |
| } |
| |
| #pragma mark - UIView |
| |
| - (CGSize)sizeThatFits:(CGSize)size { |
| return CGSizeMake(size.width, self.minMaxHeight.minimumHeightWithTopSafeArea); |
| } |
| |
| - (void)layoutSubviews { |
| [super layoutSubviews]; |
| |
| [self fhv_updateShadowColor]; |
| [self fhv_updateShadowPath]; |
| |
| [CATransaction begin]; |
| BOOL allowCAActions = _isAnimatingLayoutUpdate; |
| [CATransaction setDisableActions:!allowCAActions]; |
| _defaultShadowLayer.frame = self.bounds; |
| _customShadowLayer.frame = self.bounds; |
| _shadowLayer.frame = self.bounds; |
| [CATransaction commit]; |
| } |
| |
| - (void)willMoveToSuperview:(UIView *)newSuperview { |
| [super willMoveToSuperview:newSuperview]; |
| |
| if (newSuperview == self.trackingScrollView) { |
| self.transform = CGAffineTransformMakeTranslation(0, self.trackingScrollView.contentOffset.y); |
| } |
| } |
| |
| - (void)willMoveToWindow:(UIWindow *)newWindow { |
| [super willMoveToWindow:newWindow]; |
| |
| _wasStatusBarHiddenIsValid = NO; |
| } |
| |
| - (void)didMoveToWindow { |
| [super didMoveToWindow]; |
| |
| [_statusBarShifter didMoveToWindow]; |
| } |
| |
| - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { |
| UIView *hitView = [super hitTest:point withEvent:event]; |
| |
| // Forwards taps to the scroll view. |
| if (hitView == self || (_contentView != nil && hitView == _contentView) || |
| [_forwardingViews containsObject:hitView]) { |
| hitView = _trackingScrollView; |
| } |
| |
| return hitView; |
| } |
| |
| - (void)setFrame:(CGRect)frame { |
| [super setFrame:frame]; |
| |
| if (!_interfaceOrientationIsChanging) { |
| [self fhv_updateLayout]; |
| } |
| } |
| |
| - (void)setBackgroundColor:(UIColor *)backgroundColor { |
| [super setBackgroundColor:backgroundColor]; |
| |
| // Update default shadow to match |
| _defaultShadowLayer.backgroundColor = self.backgroundColor.CGColor; |
| } |
| |
| - (void)safeAreaInsetsDidChange { |
| if (@available(iOS 11.0, *)) { |
| [super safeAreaInsetsDidChange]; |
| |
| [_topSafeArea safeAreaInsetsDidChange]; |
| } |
| } |
| |
| #pragma mark - Top Safe Area Inset |
| |
| - (id)topSafeAreaGuide { |
| return _topSafeAreaGuide; |
| } |
| |
| - (CGFloat)topSafeAreaGuideHeight { |
| return _topSafeAreaGuide.frame.size.height; |
| } |
| |
| - (BOOL)trackingScrollViewIsWebKit { |
| return [self.trackingScrollView.superview.class isSubclassOfClass:_wkWebViewClass]; |
| } |
| |
| #pragma mark - MDCFlexibleHeaderSafeAreas |
| |
| - (void)setInferTopSafeAreaInsetFromViewController:(BOOL)inferTopSafeAreaInsetFromViewController { |
| _topSafeArea.inferTopSafeAreaInsetFromViewController = inferTopSafeAreaInsetFromViewController; |
| } |
| |
| - (BOOL)inferTopSafeAreaInsetFromViewController { |
| return _topSafeArea.inferTopSafeAreaInsetFromViewController; |
| } |
| |
| - (void)setTopSafeAreaSourceViewController:(UIViewController *)topSafeAreaSourceViewController { |
| _topSafeArea.topSafeAreaSourceViewController = topSafeAreaSourceViewController; |
| } |
| |
| - (UIViewController *)topSafeAreaSourceViewController { |
| return _topSafeArea.topSafeAreaSourceViewController; |
| } |
| |
| - (void)setSubtractsAdditionalSafeAreaInsets:(BOOL)subtractsAdditionalSafeAreaInsets { |
| _topSafeArea.subtractsAdditionalSafeAreaInsets = subtractsAdditionalSafeAreaInsets; |
| } |
| |
| - (BOOL)subtractsAdditionalSafeAreaInsets { |
| return _topSafeArea.subtractsAdditionalSafeAreaInsets; |
| } |
| |
| - (void)topSafeAreaInsetDidChange { |
| [_topSafeArea safeAreaInsetsDidChange]; |
| } |
| |
| #pragma mark MDCFlexibleHeaderTopSafeAreaDelegate |
| |
| - (void)flexibleHeaderSafeAreaTopSafeAreaInsetDidChange:(MDCFlexibleHeaderTopSafeArea *)safeAreas { |
| [self.minMaxHeight recalculateMinMaxHeight]; |
| |
| const CGFloat topSafeAreaInset = [_topSafeArea topSafeAreaInset]; |
| _topSafeAreaGuide.frame = CGRectMake(0, 0, self.bounds.size.width, topSafeAreaInset); |
| |
| // Adjust the scroll view insets to account for the new Safe Area inset. |
| [self fhv_enforceInsetsForScrollView:_trackingScrollView]; |
| |
| // Ignore any content offset delta that occured as a result of any safe area insets change. |
| _shiftAccumulatorLastContentOffset = [self fhv_boundedContentOffset]; |
| |
| if (_shifter.behavior == MDCFlexibleHeaderShiftBehaviorHideable && _wantsToBeHidden && |
| !_shiftAccumulatorDisplayLink) { |
| // Using the new safe area information, immediately shift the header such that it is off-screen. |
| _shiftAccumulator = self.fhv_accumulatorMax; |
| [self fhv_commitAccumulatorToFrame]; |
| } else if (!_trackingScrollView) { |
| // The changes might require us to re-calculate the frame, or update the entire layout. |
| CGRect bounds = self.bounds; |
| bounds.size.height = self.minMaxHeight.minimumHeightWithTopSafeArea; |
| self.bounds = bounds; |
| CGPoint position = self.center; |
| position.y = -MIN([self fhv_accumulatorMax], _shiftAccumulator); |
| position.y += self.bounds.size.height / 2; |
| self.center = position; |
| [self.delegate flexibleHeaderViewFrameDidChange:self]; |
| } else { |
| [self fhv_updateLayout]; |
| } |
| } |
| |
| - (BOOL)flexibleHeaderSafeAreaIsStatusBarShifted:(MDCFlexibleHeaderTopSafeArea *)safeAreas { |
| return ([self fhv_canShiftOffscreen] && |
| (_shifter.behavior == MDCFlexibleHeaderShiftBehaviorEnabledWithStatusBar || |
| _shifter.behavior == MDCFlexibleHeaderShiftBehaviorHideable) && |
| _statusBarShifter.prefersStatusBarHidden); |
| } |
| |
| - (CGFloat)flexibleHeaderSafeAreaDeviceTopSafeAreaInset:(MDCFlexibleHeaderTopSafeArea *)safeAreas { |
| return MDCDeviceTopSafeAreaInset(); |
| } |
| |
| #pragma mark - MDCFlexibleHeaderMinMaxHeightDelegate |
| |
| - (void)flexibleHeaderMaximumHeightDidChange:(MDCFlexibleHeaderMinMaxHeight *)safeAreas { |
| [self fhv_adjustTrackingScrollViewInsetsForTrackingScrollView:_trackingScrollView]; |
| } |
| |
| - (void)flexibleHeaderMinMaxHeightDidChange:(MDCFlexibleHeaderMinMaxHeight *)safeAreas { |
| [self fhv_updateLayout]; |
| } |
| |
| #pragma mark - Private (fhv_ prefix) |
| |
| - (void)fhv_setContentOffset:(CGPoint)contentOffset |
| forTrackingScrollView:(UIScrollView *)trackingScrollView { |
| // Avoid excessive writes. This can also cause infinite recursion if we're observing the content |
| // offset because of observesTrackingScrollViewScrollEvents. |
| if (!CGPointEqualToPoint(contentOffset, trackingScrollView.contentOffset)) { |
| trackingScrollView.contentOffset = contentOffset; |
| } |
| |
| // When we manually set our content offset it's because we're trying to avoid any sort of content |
| // jumping behavior, so we ignore immediate content offset delta by resetting the shift |
| // accumulator last content offset to the new content offset: |
| _shiftAccumulatorLastContentOffset = [self fhv_boundedContentOffset]; |
| } |
| |
| - (void)fhv_adjustTrackingScrollViewInsetsForTrackingScrollView:(UIScrollView *)trackingScrollView { |
| CGPoint offsetPriorToInsetAdjustment = trackingScrollView.contentOffset; |
| [self fhv_enforceInsetsForScrollView:trackingScrollView]; |
| |
| // Only restore the content offset if UIScrollView didn't decide to update the content offset for |
| // us. Notably, it seems to automatically adjust the content offset in the first runloop in which |
| // the scroll view's been created, but not in any further runloops. |
| if (CGPointEqualToPoint(offsetPriorToInsetAdjustment, trackingScrollView.contentOffset)) { |
| CGFloat scrollViewAdjustedContentInsetTop = trackingScrollView.contentInset.top; |
| if (@available(iOS 11.0, *)) { |
| scrollViewAdjustedContentInsetTop = trackingScrollView.adjustedContentInset.top; |
| } |
| offsetPriorToInsetAdjustment.y = |
| MAX(offsetPriorToInsetAdjustment.y, -scrollViewAdjustedContentInsetTop); |
| [self fhv_setContentOffset:offsetPriorToInsetAdjustment |
| forTrackingScrollView:trackingScrollView]; |
| } |
| } |
| |
| - (void)fhv_removeInsetsFromScrollView:(UIScrollView *)scrollView { |
| NSAssert(scrollView != _trackingScrollView, |
| @"Invalid attempt to remove insets from the tracking scroll view."); |
| if (!scrollView || scrollView == _trackingScrollView) { |
| return; |
| } |
| |
| MDCFlexibleHeaderScrollViewInfo *info = [_trackedScrollViews objectForKey:scrollView]; |
| if (!info) { |
| return; |
| } |
| |
| UIEdgeInsets insets = scrollView.contentInset; |
| insets.top -= info.injectedTopContentInset; |
| info.injectedTopContentInset = 0; |
| info.hasInjectedTopContentInset = NO; |
| scrollView.contentInset = insets; |
| |
| UIEdgeInsets scrollIndicatorInsets = scrollView.scrollIndicatorInsets; |
| scrollIndicatorInsets.top -= info.injectedTopScrollIndicatorInset; |
| info.injectedTopScrollIndicatorInset = 0; |
| scrollView.scrollIndicatorInsets = scrollIndicatorInsets; |
| } |
| |
| - (CGFloat)fhv_existingContentInsetAdjustmentForScrollView:(UIScrollView *)scrollView { |
| CGFloat existingContentInsetAdjustment = 0; |
| |
| if (@available(iOS 11.0, *)) { |
| existingContentInsetAdjustment = |
| (scrollView.adjustedContentInset.top - scrollView.contentInset.top); |
| } |
| |
| return existingContentInsetAdjustment; |
| } |
| |
| // Ensures that our tracking scroll view's top content inset matches our desired content inset. |
| // |
| // Our desired top content inset is always at least: |
| // |
| // _maximumHeight (with safe area insets removed) + [_safeAreas topSafeAreaInset] |
| // |
| // This ensures that when our scroll view is scrolled to its top that our header is able to be fully |
| // expanded. |
| - (CGFloat)fhv_enforceInsetsForScrollView:(UIScrollView *)scrollView { |
| if (!scrollView || |
| (self.useAdditionalSafeAreaInsetsForWebKitScrollViews && [self trackingScrollViewIsWebKit])) { |
| return 0; |
| } |
| if (@available(iOS 11.0, *)) { |
| // Don't adjust the contentInset if scrollView's behavior doesn't want it. |
| // Compatible to iOS 11 and above |
| if (self.disableContentInsetAdjustmentWhenContentInsetAdjustmentBehaviorIsNever && |
| scrollView.contentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentNever) { |
| return 0; |
| } |
| } |
| |
| MDCFlexibleHeaderScrollViewInfo *info = [_trackedScrollViews objectForKey:scrollView]; |
| if (!info) { |
| info = [[MDCFlexibleHeaderScrollViewInfo alloc] init]; |
| [_trackedScrollViews setObject:info forKey:scrollView]; |
| if (_trackingScrollView == scrollView) { |
| _trackingInfo = info; |
| } |
| } |
| |
| CGFloat existingContentInsetAdjustment = |
| [self fhv_existingContentInsetAdjustmentForScrollView:scrollView]; |
| CGFloat desiredTopInset = |
| self.minMaxHeight.maximumHeightWithTopSafeArea - existingContentInsetAdjustment; |
| |
| // During modal presentation on non-X devices our top safe area inset can be much larger than it |
| // actually will be, causing desiredTopInset to be small or even negative. To guard against this, |
| // we ensure that our desired top inset is always at least the header height. |
| CGFloat minimumTopInset = self.minMaxHeight.maximumHeightWithoutTopSafeArea; |
| desiredTopInset = MAX(minimumTopInset, desiredTopInset); |
| |
| UIEdgeInsets insets = scrollView.contentInset; |
| CGFloat topInsetAdjustment = 0; |
| if (!info.hasInjectedTopContentInset) { |
| topInsetAdjustment = desiredTopInset; |
| } else { |
| topInsetAdjustment = desiredTopInset - info.injectedTopContentInset; |
| } |
| insets.top += topInsetAdjustment; |
| info.injectedTopContentInset = desiredTopInset; |
| info.hasInjectedTopContentInset = YES; |
| if (!MDCEdgeInsetsEqualToEdgeInsets(scrollView.contentInset, insets)) { |
| scrollView.contentInset = insets; |
| } |
| |
| BOOL statusBarIsHidden = [UIApplication mdc_safeSharedApplication].statusBarHidden ? YES : NO; |
| if (_wasStatusBarHiddenIsValid && _wasStatusBarHidden != statusBarIsHidden && |
| !_isChangingStatusBarVisibility && !self.inferTopSafeAreaInsetFromViewController) { |
| // Our status bar state has changed without our knowledge. UIKit will have already adjusted our |
| // content offset by now, so we want to counteract this. This logic is similar to that found in |
| // statusBarShifterNeedsStatusBarAppearanceUpdate: |
| CGPoint contentOffset = scrollView.contentOffset; |
| contentOffset.y -= topInsetAdjustment; |
| [self fhv_setContentOffset:contentOffset forTrackingScrollView:_trackingScrollView]; |
| } |
| |
| _wasStatusBarHidden = statusBarIsHidden; |
| _wasStatusBarHiddenIsValid = YES; |
| |
| return topInsetAdjustment; |
| } |
| |
| - (void)fhv_updateShadowPath { |
| UIBezierPath *path = |
| [UIBezierPath bezierPathWithRect:CGRectInset(self.bounds, -self.layer.shadowRadius, 0)]; |
| self.layer.shadowPath = [path CGPath]; |
| } |
| |
| - (void)fhv_updateShadowColor { |
| _defaultShadowLayer.shadowColor = self.shadowColor.CGColor; |
| _customShadowLayer.shadowColor = self.shadowColor.CGColor; |
| _shadowLayer.shadowColor = self.shadowColor.CGColor; |
| } |
| |
| #pragma mark Typically-used values |
| |
| // Returns the contentOffset of the tracking scroll view bounded to the range of content offsets |
| // that will affect the header. |
| - (CGPoint)fhv_boundedContentOffset { |
| // We don't care about rubber banding beyond the bottom of the content. |
| return CGPointMake(_trackingScrollView.contentOffset.x, |
| MIN(_trackingScrollView.contentOffset.y, [self fhv_contentOffsetMaxY])); |
| } |
| |
| - (CGFloat)fhv_rawTopContentInset { |
| UIEdgeInsets contentInset = _trackingScrollView.contentInset; |
| if (@available(iOS 13.0, *)) { |
| // As of iOS 13, UIRefreshControl does no longer adjust the contentInset directly. Using |
| // adjustedContentInset directly does not work here (as other things adjust the content inset as |
| // well), so this explicitly checks for a UIRefreshControl and adds its size here. |
| if ([_trackingScrollView.refreshControl isRefreshing]) { |
| contentInset.top += CGRectGetHeight(_trackingScrollView.refreshControl.frame); |
| } |
| } |
| return contentInset.top - _trackingInfo.injectedTopContentInset; |
| } |
| |
| - (CGFloat)fhv_contentOffsetWithoutInjectedTopInset { |
| return _trackingScrollView.contentOffset.y + [self fhv_rawTopContentInset]; |
| } |
| |
| - (CGFloat)fhv_contentOffsetMaxY { |
| return _trackingScrollView.contentSize.height - _trackingScrollView.bounds.size.height; |
| } |
| |
| // Returns a value indicating how much the header is overlapping the tracking scroll view's content. |
| // > 0 overlapping the content |
| // = 0 attached to top of content |
| // < 0 the content is below the header |
| - (CGFloat)fhv_projectedHeaderBottomEdge { |
| CGFloat offsetWithoutInset = [self fhv_contentOffsetWithoutInjectedTopInset]; |
| CGRect projectedFrame = [self convertRect:self.bounds toView:self.trackingScrollView.superview]; |
| CGFloat frameBottomEdge = CGRectGetMaxY(projectedFrame); |
| return frameBottomEdge + offsetWithoutInset; |
| } |
| |
| - (CGFloat)fhv_accumulatorMax { |
| BOOL shouldCollapseToStatusBar = [self fhv_shouldCollapseToStatusBar]; |
| CGFloat statusBarHeight = [UIApplication mdc_safeSharedApplication].statusBarFrame.size.height; |
| return (shouldCollapseToStatusBar |
| ? MAX(0, self.minMaxHeight.minimumHeightWithTopSafeArea - statusBarHeight) |
| : self.minMaxHeight.minimumHeightWithTopSafeArea) - |
| self.minimumHeaderViewHeight; |
| } |
| |
| #pragma mark Logical short forms |
| |
| - (BOOL)fhv_shouldAllowShifting { |
| return _shifter.hidesStatusBarWhenShiftedOffscreen && self.statusBarHintCanOverlapHeader; |
| } |
| |
| - (BOOL)fhv_shouldCollapseToStatusBar { |
| return !_shifter.hidesStatusBarWhenShiftedOffscreen && self.statusBarHintCanOverlapHeader; |
| } |
| |
| - (BOOL)fhv_canShiftOffscreen { |
| BOOL interactable = ((_shifter.behavior == MDCFlexibleHeaderShiftBehaviorEnabled || |
| _shifter.behavior == MDCFlexibleHeaderShiftBehaviorEnabledWithStatusBar) && |
| !_trackingScrollView.pagingEnabled); |
| BOOL hideable = _shifter.behavior == MDCFlexibleHeaderShiftBehaviorHideable; |
| return interactable || hideable; |
| } |
| |
| - (BOOL)fhv_isPartiallyShifted { |
| return ([self fhv_isDetachedFromTopOfContent] && _shiftAccumulator > 0 && |
| _shiftAccumulator < [self fhv_accumulatorMax]); |
| } |
| |
| - (BOOL)fhv_isFullyShifted { |
| return ([self fhv_isDetachedFromTopOfContent] && _shiftAccumulator > 0 && |
| _shiftAccumulator >= [self fhv_accumulatorMax]); |
| } |
| |
| - (BOOL)fhv_isPartiallyExpanded { |
| return ([self fhv_isDetachedFromTopOfContent] && _shiftAccumulator < 0 && |
| _shiftAccumulator > -(self.maximumHeight - self.minimumHeight)); |
| } |
| |
| // The flexible header is "in front of" the content. |
| - (BOOL)fhv_isDetachedFromTopOfContent { |
| // Epsilon here is somewhat large in order to be visually-forgiving for sub-point situations. |
| return [self fhv_projectedHeaderBottomEdge] > (CGFloat)0.5; |
| } |
| |
| - (BOOL)fhv_isOverExtendingBottom { |
| CGFloat bottomEdgeOfScrollView = |
| (_trackingScrollView.contentOffset.y + _trackingScrollView.bounds.size.height); |
| CGFloat bottomEdgeOfContent = |
| (_trackingScrollView.contentSize.height + _trackingScrollView.contentInset.bottom); |
| BOOL canOverExtendBottom = |
| (_trackingScrollView.contentSize.height > _trackingScrollView.bounds.size.height); |
| return (canOverExtendBottom && (bottomEdgeOfScrollView >= bottomEdgeOfContent)); |
| } |
| |
| #pragma mark Phase Calculation |
| |
| // Given the current frame, calculates the scroll phase, value, and percentage. |
| - (void)fhv_recalculatePhase { |
| CGRect frame = self.frame; |
| |
| CGFloat topEdge = self.center.y - self.bounds.size.height / 2; |
| |
| if (topEdge < 0) { |
| _scrollPhase = MDCFlexibleHeaderScrollPhaseShifting; |
| _scrollPhaseValue = topEdge + self.minMaxHeight.minimumHeightWithTopSafeArea; |
| CGFloat adjustedHeight = self.minMaxHeight.minimumHeightWithTopSafeArea; |
| if ([self fhv_shouldCollapseToStatusBar]) { |
| CGFloat statusBarHeight = |
| [UIApplication mdc_safeSharedApplication].statusBarFrame.size.height; |
| adjustedHeight -= statusBarHeight; |
| } |
| if (adjustedHeight > 0) { |
| _scrollPhasePercentage = -topEdge / adjustedHeight; |
| } else { |
| _scrollPhasePercentage = 0; |
| } |
| |
| return; |
| } |
| |
| _scrollPhaseValue = frame.size.height; |
| |
| if (frame.size.height < self.minMaxHeight.maximumHeightWithTopSafeArea) { |
| _scrollPhase = MDCFlexibleHeaderScrollPhaseCollapsing; |
| |
| CGFloat heightLength = self.minMaxHeight.maximumHeightWithTopSafeArea - |
| self.minMaxHeight.minimumHeightWithTopSafeArea; |
| if (heightLength > 0) { |
| _scrollPhasePercentage = |
| (frame.size.height - self.minMaxHeight.minimumHeightWithTopSafeArea) / heightLength; |
| } else { |
| _scrollPhasePercentage = 0; |
| } |
| |
| return; |
| } |
| |
| _scrollPhase = MDCFlexibleHeaderScrollPhaseOverExtending; |
| if (self.minMaxHeight.maximumHeightWithTopSafeArea > 0) { |
| _scrollPhasePercentage = |
| 1 + (frame.size.height - self.minMaxHeight.maximumHeightWithTopSafeArea) / |
| self.minMaxHeight.maximumHeightWithTopSafeArea; |
| } else { |
| _scrollPhasePercentage = 0; |
| } |
| } |
| |
| #pragma mark Display Link |
| |
| // The display link is only active when the user is no longer interacting with the scroll view and |
| // we'd like to shift the header either on- or off-screen. |
| |
| #if TARGET_IPHONE_SIMULATOR |
| - (float)fhv_dragCoefficient { |
| if (&UIAnimationDragCoefficient) { |
| float coeff = UIAnimationDragCoefficient(); |
| if (coeff > 1) { |
| return coeff; |
| } |
| } |
| return 1; |
| } |
| #endif |
| |
| - (void)fhv_startDisplayLink { |
| [self fhv_stopDisplayLink]; |
| |
| // Because CADisplayLink retains its target, this may cause a retain cycle. |
| // See cl/129917749 |
| _shiftAccumulatorDisplayLink = |
| [CADisplayLink displayLinkWithTarget:self |
| selector:@selector(fhv_shiftAccumulatorDisplayLinkDidFire:)]; |
| [_shiftAccumulatorDisplayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; |
| } |
| |
| - (void)fhv_stopDisplayLink { |
| [_shiftAccumulatorDisplayLink invalidate]; |
| _shiftAccumulatorDisplayLink = nil; |
| } |
| |
| - (void)fhv_shiftAccumulatorDisplayLinkDidFire:(CADisplayLink *)displayLink { |
| // Erase any scrollback that was injected into the accumulator by capping it back down. |
| _shiftAccumulator = MIN([self fhv_accumulatorMax], _shiftAccumulator); |
| |
| CGFloat destination; |
| if (self.canAlwaysExpandToMaximumHeight) { |
| if (_shiftAccumulator > 0) { // Shifted |
| destination = _wantsToBeHidden ? [self fhv_accumulatorMax] : 0; |
| |
| } else if (_shiftAccumulator < 0) { // Expanded |
| destination = _wantsToBeHidden ? 0 : [self fhv_accumulatorMin]; |
| |
| } else { |
| destination = 0; |
| } |
| |
| } else { |
| destination = _wantsToBeHidden ? [self fhv_accumulatorMax] : 0; |
| } |
| |
| CGFloat distanceToDestination = destination - _shiftAccumulator; |
| |
| NSTimeInterval duration = displayLink.duration; |
| |
| #if TARGET_IPHONE_SIMULATOR |
| duration /= [self fhv_dragCoefficient]; |
| #endif |
| |
| // This is a simple "force" that's stronger the further we are from the destination. |
| _shiftAccumulator += (CGFloat)(kAttachmentCoefficient * distanceToDestination * duration); |
| |
| if (self.canAlwaysExpandToMaximumHeight) { |
| _shiftAccumulator = |
| MAX([self fhv_accumulatorMin], MIN([self fhv_accumulatorMax], _shiftAccumulator)); |
| [_statusBarShifter setOffset:MAX(0, _shiftAccumulator)]; |
| } else { |
| _shiftAccumulator = MAX(0, MIN([self fhv_accumulatorMax], _shiftAccumulator)); |
| [_statusBarShifter setOffset:_shiftAccumulator]; |
| } |
| |
| // Have we reached our destination? |
| if (fabs(destination - _shiftAccumulator) <= kShiftEpsilon) { |
| _shiftAccumulator = destination; |
| |
| [self fhv_stopDisplayLink]; |
| } |
| |
| [self fhv_commitAccumulatorToFrame]; |
| } |
| |
| #pragma mark Shift Accumulator |
| |
| - (void)fhv_accumulatorDidChange { |
| if (!_trackingScrollView) { |
| // Set the shadow opacity directly. |
| self.layer.shadowOpacity = |
| self.resetShadowAfterTrackingScrollViewIsReset && !self.isInFrontOfInfiniteContent |
| ? 0 |
| : _visibleShadowOpacity; |
| return; |
| } |
| |
| CGRect frame = self.frame; |
| |
| CGFloat frameBottomEdge = [self fhv_projectedHeaderBottomEdge]; |
| frameBottomEdge = MAX(0, MIN(kShadowScaleLength, frameBottomEdge)); |
| CGFloat boundedAccumulator; |
| if (self.canAlwaysExpandToMaximumHeight) { |
| boundedAccumulator = MAX(0, MIN([self fhv_accumulatorMax], _shiftAccumulator)); |
| } else { |
| boundedAccumulator = MIN([self fhv_accumulatorMax], _shiftAccumulator); |
| } |
| |
| CGFloat shadowIntensity; |
| if (_shifter.hidesStatusBarWhenShiftedOffscreen) { |
| // Calculate the desired shadow strength for the offset & accumulator and then take the |
| // weakest strength. |
| CGFloat accumulator = |
| MAX(0, MIN(kShadowScaleLength, |
| self.minMaxHeight.minimumHeightWithTopSafeArea - boundedAccumulator)); |
| if (self.isInFrontOfInfiniteContent) { |
| // When in front of infinite content we only care to hide the shadow when our header is |
| // off-screen. |
| shadowIntensity = MAX(0, MIN(1, accumulator / kShadowScaleLength)); |
| |
| } else { |
| // When over non-infinite content we also want to hide the shadow when we're anchored to the |
| // top of our content. |
| shadowIntensity = MAX(0, MIN(1, MIN(accumulator, frameBottomEdge) / kShadowScaleLength)); |
| } |
| } else if (self.isInFrontOfInfiniteContent) { |
| shadowIntensity = 1; |
| } else { |
| // Adjust the opacity as the bottom edge of the header increasingly overlaps the contents |
| shadowIntensity = frameBottomEdge / kShadowScaleLength; |
| } |
| |
| if (_defaultShadowLayer.hidden && _customShadowLayer.hidden) { |
| self.layer.shadowOpacity = (float)(_visibleShadowOpacity * shadowIntensity); |
| } else { |
| _defaultShadowLayer.shadowOpacity = (float)(_visibleShadowOpacity * shadowIntensity); |
| } |
| _shadowIntensity = shadowIntensity; |
| if (_shadowIntensityChangeBlock) { |
| _shadowIntensityChangeBlock(_shadowLayer, _shadowIntensity); |
| } |
| |
| [_statusBarShifter setOffset:boundedAccumulator]; |
| |
| // Small performance improvement to not set the hidden property on every scroll tick. |
| BOOL isShiftedOffscreen = boundedAccumulator >= self.minMaxHeight.minimumHeightWithTopSafeArea; |
| BOOL isFullyCollapsed = |
| frame.size.height <= self.minMaxHeight.minimumHeightWithTopSafeArea + DBL_EPSILON; |
| BOOL isHidden = isShiftedOffscreen && isFullyCollapsed; |
| if (isHidden != self.hidden) { |
| self.hidden = isHidden; |
| } |
| |
| UIEdgeInsets scrollIndicatorInsets = _trackingScrollView.scrollIndicatorInsets; |
| scrollIndicatorInsets.top -= _trackingInfo.injectedTopScrollIndicatorInset; |
| |
| CGFloat existingContentInsetAdjustment = |
| [self fhv_existingContentInsetAdjustmentForScrollView:_trackingScrollView]; |
| |
| _trackingInfo.injectedTopScrollIndicatorInset = |
| (frame.size.height - boundedAccumulator - existingContentInsetAdjustment); |
| |
| scrollIndicatorInsets.top += _trackingInfo.injectedTopScrollIndicatorInset; |
| _trackingScrollView.scrollIndicatorInsets = scrollIndicatorInsets; |
| } |
| |
| #pragma mark Layout |
| |
| - (CGFloat)fhv_accumulatorMin { |
| CGFloat headerHeight = -[self fhv_contentOffsetWithoutInjectedTopInset]; |
| |
| CGFloat lowerBound; |
| |
| if (self.canAlwaysExpandToMaximumHeight) { |
| CGFloat maxExpansion; |
| if (headerHeight < self.minMaxHeight.minimumHeightWithTopSafeArea) { |
| // The header is detached from the content and able to fully expand. |
| maxExpansion = self.maximumHeight - self.minimumHeight; |
| } else { |
| // We're now attached to the content and need to constrain our possible expansion. |
| maxExpansion = self.minMaxHeight.maximumHeightWithTopSafeArea - headerHeight; |
| } |
| // Expansion is tracked via negative accumulation. |
| lowerBound = MIN(0, -maxExpansion); |
| } else { |
| lowerBound = 0; |
| } |
| |
| return lowerBound; |
| } |
| |
| - (void)fhv_updateLayout { |
| if (!_trackingScrollView) { |
| return; |
| } |
| |
| // Update the min and max height if we're still using the defaults. |
| // Safe area insets is often called as part of the UIWindow makeKeyAndVisible callstack, meaning |
| // MDCDeviceTopSafeAreaInset returns an incorrect "best guess" value and we end up storing an |
| // incorrect min/max height. In order to update min/max to the correct heights we need to update |
| // our dimensions sometime after the window has been been made key, so the next best place is |
| // here. |
| [self.minMaxHeight recalculateMinMaxHeight]; |
| |
| // If the status bar changes without us knowing then this ensures that our content insets |
| // are up-to-date before we process the content offset. |
| [self fhv_enforceInsetsForScrollView:_trackingScrollView]; |
| |
| // We use the content offset to calculate the unclamped height of the frame. |
| CGFloat offsetWithoutInset = [self fhv_contentOffsetWithoutInjectedTopInset]; |
| CGFloat headerHeight = -offsetWithoutInset; |
| |
| if (_trackingScrollView.isTracking) { |
| [self fhv_stopDisplayLink]; |
| } |
| |
| // When the shift behavior is MDCFlexibleHeaderShiftBehaviorHideable, we explicitly disable |
| // interactive shifting behaviors so that the header's visibility is controlled only via direct |
| // invocations to -shiftHeaderOnScreenAnimated: and shiftHeaderOffScreenAnimated: |
| BOOL allowsInteractiveShift = _shifter.behavior != MDCFlexibleHeaderShiftBehaviorHideable; |
| |
| if (_shiftAccumulatorLastContentOffsetIsValid && allowsInteractiveShift) { |
| // We track the last direction for our target offset behavior. |
| CGFloat deltaY = [self fhv_boundedContentOffset].y - _shiftAccumulatorLastContentOffset.y; |
| |
| if (_shiftAccumulatorDeltaY * deltaY < 0) { |
| // Direction has changed. |
| _shiftAccumulatorDeltaY = 0; |
| } |
| _shiftAccumulatorDeltaY += deltaY; |
| |
| // Keeps track of the last direction the user moved their finger in. |
| if (_trackingScrollView.isTracking) { |
| if (_shiftAccumulatorDeltaY > kDeltaYSlop) { |
| _wantsToBeHidden = YES; |
| } else if (_shiftAccumulatorDeltaY < -kDeltaYSlop) { |
| _wantsToBeHidden = NO; |
| } |
| } |
| |
| if (![self fhv_isOverExtendingBottom] && !_shiftAccumulatorDisplayLink) { |
| if (!self.canAlwaysExpandToMaximumHeight) { |
| // When we're not allowed to shift offscreen, only allow the header to shift further |
| // on-screen in case it was previously off-screen due to a behavior change. |
| if (![self fhv_canShiftOffscreen]) { |
| deltaY = MIN(0, deltaY); |
| } |
| } |
| |
| // When scrubbing we only allow the header to shrink and shift off-screen. |
| if (self.trackingScrollViewIsBeingScrubbed) { |
| deltaY = MAX(0, deltaY); |
| } |
| |
| if (self.canAlwaysExpandToMaximumHeight) { |
| // When still attached to the top content, don't accumulate negatively. |
| if (headerHeight >= self.minMaxHeight.minimumHeightWithTopSafeArea) { |
| deltaY = MAX(0, deltaY); |
| } |
| } |
| |
| // Check if our delta y will cause us to cross the boundary from shrinking to shifting and, |
| // if so, cap the deltaY to only the overshoot, otherwise the header will overshift. |
| |
| // headerHeight and deltaY are in inverted coordinate spaces, so when we do |
| // headerHeight + deltaY we're calculating where the headerHeight was _before_ this update. |
| |
| CGFloat previousHeaderHeight = headerHeight + deltaY; |
| |
| // Overshoot coming in |
| if (headerHeight < self.minMaxHeight.minimumHeightWithTopSafeArea && |
| previousHeaderHeight > self.minMaxHeight.minimumHeightWithTopSafeArea) { |
| deltaY = self.minMaxHeight.minimumHeightWithTopSafeArea - headerHeight; |
| |
| // Overshoot going out |
| } else if (headerHeight > self.minMaxHeight.minimumHeightWithTopSafeArea && |
| previousHeaderHeight < self.minMaxHeight.minimumHeightWithTopSafeArea) { |
| deltaY = (headerHeight + deltaY) - self.minMaxHeight.minimumHeightWithTopSafeArea; |
| } |
| |
| // Calculate the upper bound of the accumulator based on what phase we're in. |
| CGFloat upperBound = [self upperBoundWithHeaderHeight:headerHeight]; |
| |
| // Ensure that we don't lose any deltaY by first capping the accumulator within its valid |
| // range. |
| _shiftAccumulator = MIN(upperBound, _shiftAccumulator); |
| |
| // Accumulate the deltaY. |
| if (self.canAlwaysExpandToMaximumHeight) { |
| CGFloat lowerBound = [self fhv_accumulatorMin]; |
| _shiftAccumulator = MAX(lowerBound, MIN(upperBound, _shiftAccumulator + deltaY)); |
| } else { |
| _shiftAccumulator = MAX(0, MIN(upperBound, _shiftAccumulator + deltaY)); |
| } |
| } |
| } |
| |
| if (!self.canAlwaysExpandToMaximumHeight) { |
| CGRect bounds = self.bounds; |
| if (_canOverExtend && !UIAccessibilityIsVoiceOverRunning()) { |
| bounds.size.height = MAX(self.minMaxHeight.minimumHeightWithTopSafeArea, headerHeight); |
| } else { |
| bounds.size.height = MAX(self.minMaxHeight.minimumHeightWithTopSafeArea, |
| MIN(self.minMaxHeight.maximumHeightWithTopSafeArea, headerHeight)); |
| } |
| self.bounds = bounds; |
| } |
| |
| [self fhv_commitAccumulatorToFrame]; |
| |
| _shiftAccumulatorLastContentOffset = [self fhv_boundedContentOffset]; |
| _shiftAccumulatorLastContentOffsetIsValid = YES; |
| } |
| |
| - (CGFloat)upperBoundWithHeaderHeight:(CGFloat)headerHeight { |
| CGFloat upperBound; |
| if (self.canAlwaysExpandToMaximumHeight && ![self fhv_canShiftOffscreen]) { |
| // Don't allow any shifting. |
| upperBound = 0; |
| } else if (headerHeight < 0) { |
| if (self.minimumHeaderViewHeight != 0.0) { |
| // Set upperBound distance to be between |
| // |maximum height| and |remaining minimum height after shifting|. |
| upperBound = self.minMaxHeight.maximumHeightWithoutTopSafeArea - self.minimumHeaderViewHeight; |
| } else { |
| upperBound = [self fhv_accumulatorMax] + [self fhv_anchorLength]; |
| } |
| } else if (headerHeight < self.minMaxHeight.minimumHeightWithTopSafeArea) { |
| if (self.minimumHeaderViewHeight != 0.0) { |
| // Set upperBound distance to be between |
| // |maximum height| and |remaining minimum height after shifting|. |
| upperBound = self.minMaxHeight.maximumHeightWithoutTopSafeArea - self.minimumHeaderViewHeight; |
| } else { |
| upperBound = [self fhv_accumulatorMax]; |
| } |
| |
| } else { |
| // Header is not shifting. |
| upperBound = 0; |
| } |
| return upperBound; |
| } |
| |
| - (CGFloat)fhv_anchorLength { |
| switch (_headerContentImportance) { |
| case MDCFlexibleHeaderContentImportanceDefault: |
| return kMaxAnchorLengthFullSwipe; |
| |
| case MDCFlexibleHeaderContentImportanceHigh: |
| return kMaxAnchorLengthQuickSwipe; |
| } |
| } |
| |
| // Commit the current shiftOffscreenAccumulator value to the view's position. |
| - (void)fhv_commitAccumulatorToFrame { |
| if (self.canAlwaysExpandToMaximumHeight) { |
| CGFloat offsetWithoutInset = [self fhv_contentOffsetWithoutInjectedTopInset]; |
| CGFloat headerHeight = -offsetWithoutInset; |
| CGRect bounds = self.bounds; |
| |
| CGFloat additionalHeightInjection = MAX(0, -_shiftAccumulator); |
| |
| if (_canOverExtend && !UIAccessibilityIsVoiceOverRunning()) { |
| bounds.size.height = MAX(self.minMaxHeight.minimumHeightWithTopSafeArea, headerHeight) + |
| additionalHeightInjection; |
| } else { |
| bounds.size.height = (MAX(self.minMaxHeight.minimumHeightWithTopSafeArea, |
| MIN(self.minMaxHeight.maximumHeightWithTopSafeArea, headerHeight)) + |
| additionalHeightInjection); |
| } |
| |
| // Avoid excessive writes - the default behavior of the flexible header has minimal height |
| // adjustment behavior (basically only when over-extending). |
| if (!CGRectEqualToRect(self.bounds, bounds)) { |
| self.bounds = bounds; |
| } |
| } |
| |
| CGPoint position = self.center; |
| CGFloat shiftOffset; |
| if (self.canAlwaysExpandToMaximumHeight) { |
| shiftOffset = MAX(0, MIN([self fhv_accumulatorMax], _shiftAccumulator)); |
| } else { |
| shiftOffset = MIN([self fhv_accumulatorMax], _shiftAccumulator); |
| } |
| // Offset the frame. |
| position.y = -shiftOffset; |
| position.y += self.bounds.size.height / 2; |
| |
| self.center = position; |
| |
| [self fhv_accumulatorDidChange]; |
| [self fhv_recalculatePhase]; |
| |
| CGFloat opacityShiftThreshold = [self fhv_accumulatorMax] * kContentHidingThreshold; |
| // 0% means not shifted at all, 100% means shifted up to our threshold amount. |
| CGFloat percentShiftedAlongThreshold = MIN(1, MAX(0, shiftOffset / opacityShiftThreshold)); |
| for (UIView *view in _viewsToHideWhenShifted) { |
| // When not shifted at all, we want to be fully visible. We invert the percentage to get our |
| // desired alpha. |
| view.alpha = 1 - percentShiftedAlongThreshold; |
| } |
| |
| [_statusBarShifter setOffset:_shiftAccumulator]; |
| |
| [self.delegate flexibleHeaderViewFrameDidChange:self]; |
| } |
| |
| - (void)fhv_contentOffsetDidChange { |
| #if DEBUG |
| _didAdjustTargetContentOffset = NO; |
| #endif |
| |
| if (_trackingInfo.shouldIgnoreNextSafeAreaAdjustment) { |
| _trackingInfo.shouldIgnoreNextSafeAreaAdjustment = NO; |
| |
| if (_shiftAccumulatorLastContentOffsetIsValid) { |
| CGFloat delta = (CGFloat)fabs(_shiftAccumulatorLastContentOffset.y - |
| self.trackingScrollView.contentOffset.y); |
| if (fabs(delta - [_topSafeArea topSafeAreaInset]) < kContentOffsetEpsilon) { |
| // Looks like a top safe area inset adjustment. Let's ignore it. |
| self.trackingScrollView.contentOffset = _shiftAccumulatorLastContentOffset; |
| return; |
| } |
| } |
| _shiftAccumulatorLastContentOffsetIsValid = NO; |
| } |
| |
| if (self.trackingScrollView.isTracking) { |
| _didDecelerate = NO; // Invalidate the flag - we're not actually decelerating right now. |
| } |
| |
| // We generally expect the tracking scroll view to be a sibling to the flexible header, but there |
| // are cases where this assumption is always incorrect. |
| // |
| // Notably, UITableViewController's .view _is_ the tableView, so there is no way to add a flexible |
| // header other than as a subview to the scroll view. This is the most common case to which the |
| // following logic has been written. |
| if (self.superview && self.superview == self.trackingScrollView) { |
| if (self.superview.subviews.lastObject != self) { |
| [self.superview bringSubviewToFront:self]; |
| } |
| |
| void (^updateTransform)(void) = ^{ |
| self.transform = CGAffineTransformMakeTranslation(0, self.trackingScrollView.contentOffset.y); |
| }; |
| |
| CAAnimation *boundsAnimation = [self.trackingScrollView.layer animationForKey:@"bounds.origin"]; |
| |
| void (^updateTransformWithInFlightAnimation)(void) = ^{ |
| // Check if there is an in-flight bounds animation and piggy-back its duration in order to |
| // avoid a jumping effect resulting from the transform otherwise updating instantly. |
| // This can happen if one of the cells positioned above the app bar shrinks in height, which |
| // causes the scroll view to animate its bounds origin in order to keep the scroll view's |
| // content from moving. |
| if (boundsAnimation) { |
| [UIView animateWithDuration:boundsAnimation.duration |
| animations:^{ |
| updateTransform(); |
| }]; |
| } else { |
| updateTransform(); |
| } |
| }; |
| |
| if (UIAccessibilityIsVoiceOverRunning()) { |
| // Clamp the offset to at least the max of -self.maximumHeight and the topContentInset. |
| // Accessibility may attempt to scroll to a lesser offset than this to pull the flexible |
| // header into the center of the scrollview on focusing. |
| CGPoint offset = self.trackingScrollView.contentOffset; |
| CGFloat scrollViewAdjustedContentInsetTop = self.trackingScrollView.contentInset.top; |
| if (@available(iOS 11.0, *)) { |
| scrollViewAdjustedContentInsetTop = self.trackingScrollView.adjustedContentInset.top; |
| } |
| // The offset clamp needs to be rounded to the closest integer due to the contentOffset being |
| // re-adjusted by UIKit to a non-fractional number. Without rounding an infinite recurion |
| // occurs, where the content offset is set to a fractional number and then UIKit re-setting |
| // it back and re-calling this method over and over. |
| CGFloat offsetClamp = round(-( |
| MAX(self.minMaxHeight.maximumHeightWithTopSafeArea, scrollViewAdjustedContentInsetTop))); |
| offset.y = MAX(offset.y, offsetClamp); |
| [self fhv_setContentOffset:offset forTrackingScrollView:self.trackingScrollView]; |
| if (boundsAnimation) { |
| // The transform will piggy-back with the in-flight bounds |
| // animation in order to avoid a jumping effect. |
| updateTransformWithInFlightAnimation(); |
| } else { |
| // Setting the transform on the same run loop as the accessibility scroll can cause |
| // additional incorrect scrolling as the scrollview attempts to resolve to a position that |
| // will place the header in the center of the scroll. Punting to the next loop prevents |
| // this. |
| dispatch_async(dispatch_get_main_queue(), ^{ |
| updateTransform(); |
| [self fhv_updateLayout]; |
| }); |
| } |
| } else { |
| updateTransformWithInFlightAnimation(); |
| } |
| } |
| |
| // While the interface orientation is rotating we don't respond to any adjustments to the content |
| // offset. |
| if (_interfaceOrientationIsChanging || _contentInsetsAreChanging || |
| _isChangingStatusBarVisibility) { |
| return; |
| } |
| |
| [self fhv_updateLayout]; |
| } |
| |
| #pragma mark TraitCollection |
| |
| - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { |
| [super traitCollectionDidChange:previousTraitCollection]; |
| |
| if (self.traitCollectionDidChangeBlock) { |
| self.traitCollectionDidChangeBlock(self, previousTraitCollection); |
| } |
| } |
| |
| #pragma mark KVO |
| |
| - (void)observeValueForKeyPath:(NSString *)keyPath |
| ofObject:(id)object |
| change:(NSDictionary *)change |
| context:(void *)context { |
| if (context == kKVOContextMDCFlexibleHeaderView) { |
| void (^mainThreadWork)(void) = ^{ |
| if (object == self.trackingScrollView) { |
| [self fhv_contentOffsetDidChange]; |
| } |
| }; |
| |
| // Ensure that UIKit modifications occur on the main thread. |
| if ([NSThread isMainThread]) { |
| mainThreadWork(); |
| } else { |
| [[NSOperationQueue mainQueue] addOperationWithBlock:mainThreadWork]; |
| } |
| |
| } else { |
| [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; |
| } |
| } |
| |
| #pragma mark Content offset observation |
| |
| - (void)fhv_startObservingContentOffset { |
| [self.trackingScrollView addObserver:self |
| forKeyPath:NSStringFromSelector(@selector(contentOffset)) |
| options:NSKeyValueObservingOptionNew |
| context:kKVOContextMDCFlexibleHeaderView]; |
| } |
| |
| - (void)fhv_stopObservingContentOffset { |
| [self.trackingScrollView removeObserver:self |
| forKeyPath:NSStringFromSelector(@selector(contentOffset)) |
| context:kKVOContextMDCFlexibleHeaderView]; |
| } |
| |
| - (void)setObservesTrackingScrollViewScrollEvents:(BOOL)observesTrackingScrollViewScrollEvents { |
| NSAssert(!observesTrackingScrollViewScrollEvents || |
| (self.shiftBehavior == MDCFlexibleHeaderShiftBehaviorDisabled || |
| self.shiftBehavior == MDCFlexibleHeaderShiftBehaviorHideable), |
| @"Please set shiftBehavior to disabled or hideable prior to enabling this property."); |
| |
| if (_observesTrackingScrollViewScrollEvents == observesTrackingScrollViewScrollEvents) { |
| return; |
| } |
| _observesTrackingScrollViewScrollEvents = observesTrackingScrollViewScrollEvents; |
| |
| if (observesTrackingScrollViewScrollEvents) { |
| [self fhv_startObservingContentOffset]; |
| } else { |
| [self fhv_stopObservingContentOffset]; |
| } |
| } |
| |
| #pragma mark Gestures |
| |
| // TODO(#1254): Re-enable sanity check assert on viewDidPan |
| // This function is a temporary inclusion to stop an assert from triggering on iOS 10.3b until |
| // we determine the cause. Remove once #1254 is closed. |
| #if DEBUG |
| static BOOL isRunningiOS10_3OrAbove() { |
| static dispatch_once_t onceToken; |
| static BOOL isRunningiOS10_3OrAbove; |
| dispatch_once(&onceToken, ^{ |
| NSProcessInfo *info = [NSProcessInfo processInfo]; |
| isRunningiOS10_3OrAbove = [info isOperatingSystemAtLeastVersion:(NSOperatingSystemVersion){ |
| .majorVersion = 10, |
| .minorVersion = 3, |
| .patchVersion = 0, |
| }]; |
| }); |
| return isRunningiOS10_3OrAbove; |
| } |
| #endif |
| |
| #if DEBUG |
| - (void)fhv_scrollViewDidPan:(UIPanGestureRecognizer *)pan { |
| if (pan.state == UIGestureRecognizerStateEnded && [self fhv_canShiftOffscreen]) { |
| // You _must_ implement the target content offset method in your UIScrollViewDelegate. |
| // Not implementing the target content offset method can allow the status bar to get into an |
| // indeterminate state and may cause your app to be rejected. |
| |
| // TODO(#1254): Re-enable sanity check assert on viewDidPan |
| // To re-enable, remove isRunningiOS10_3OrAbove() function and always assert. |
| if (!isRunningiOS10_3OrAbove()) { |
| NSAssert(_didAdjustTargetContentOffset, @"%@ isn't invoking %@'s %@.", |
| NSStringFromClass([_trackingScrollView class]), NSStringFromClass([self class]), |
| NSStringFromSelector(@selector(trackingScrollViewWillEndDraggingWithVelocity: |
| targetContentOffset:))); |
| } |
| } |
| } |
| #endif |
| |
| #pragma mark Multiple tracking scroll views |
| |
| // Given a tracking scroll view and a potential new tracking scroll view, updates the state of the |
| // header and scroll view such that header's height will not change once the scroll view becomes the |
| // new tracking scroll view. |
| - (void)fhv_matchHeightWithScrollView:(UIScrollView *)scrollView { |
| if (self.trackingScrollView == nil) { |
| return; |
| } |
| |
| MDCFlexibleHeaderScrollViewInfo *info = [_trackedScrollViews objectForKey:scrollView]; |
| |
| if (_shiftAccumulator >= [self fhv_accumulatorMax]) { |
| // We're shifted off-screen, make sure that this scroll view isn't expecting to show the header. |
| |
| CGPoint offset = scrollView.contentOffset; |
| CGFloat rawTopInset = scrollView.contentInset.top - info.injectedTopContentInset; |
| if (offset.y < -rawTopInset) { |
| offset.y = -rawTopInset; |
| scrollView.contentOffset = offset; |
| } |
| } |
| |
| if (info.stashedHeightIsValid) { |
| // Did our height change since the last time we saw this content? |
| const CGFloat heightDelta = self.bounds.size.height - info.stashedHeight; |
| if (fabs(heightDelta) > kHeightEpsilon) { |
| // Offset our content accordingly so that we're still viewing what we were viewing last time. |
| CGPoint offset = scrollView.contentOffset; |
| offset.y -= heightDelta; |
| scrollView.contentOffset = offset; |
| } |
| } |
| } |
| |
| #pragma mark - MDCStatusBarShifterDelegate |
| |
| - (void)statusBarShifterNeedsStatusBarAppearanceUpdate: |
| (__unused MDCStatusBarShifter *)statusBarShifter { |
| // UINavigationController reacts to status bar visibility changes by adjusting the content offset. |
| // To counteract this sort of behavior, we forcefully stash the content offset and restore it |
| // after updating the status bar appearance. |
| _isChangingStatusBarVisibility = YES; |
| CGPoint stashedContentOffset = _trackingScrollView.contentOffset; |
| [self.delegate flexibleHeaderViewNeedsStatusBarAppearanceUpdate:self]; |
| [self fhv_enforceInsetsForScrollView:self.trackingScrollView]; |
| [UIView performWithoutAnimation:^{ |
| [self fhv_setContentOffset:stashedContentOffset forTrackingScrollView:self.trackingScrollView]; |
| }]; |
| _isChangingStatusBarVisibility = NO; |
| } |
| |
| - (void)statusBarShifter:(__unused MDCStatusBarShifter *)statusBarShifter |
| wantsSnapshotViewAdded:(UIView *)view { |
| [self addSubview:view]; |
| } |
| |
| #pragma mark - Public |
| |
| - (void)setTrackingScrollView:(UIScrollView *)trackingScrollView { |
| if (_trackingScrollView == trackingScrollView) { |
| return; |
| } |
| |
| #if DEBUG |
| [_trackingScrollView.panGestureRecognizer removeTarget:self |
| action:@selector(fhv_scrollViewDidPan:)]; |
| [trackingScrollView.panGestureRecognizer addTarget:self action:@selector(fhv_scrollViewDidPan:)]; |
| |
| #if 0 // TODO(featherless): |
| // https://github.com/material-components/material-components-ios/issues/214 |
| // Verify existence of a delegate. |
| NSAssert(!trackingScrollView || trackingScrollView.delegate, |
| @"The provided tracking scroll view %@ has no delegate. Without a delegate, %@ will not" |
| @" be able to react to scroll events and may perform incorrectly." |
| @" This assertion will only fire in debug builds.", |
| NSStringFromClass([trackingScrollView class]), |
| NSStringFromClass([self class])); |
| #endif // #if 0 |
| #endif // #if DEBUG |
| |
| if (self.observesTrackingScrollViewScrollEvents) { |
| [self fhv_stopObservingContentOffset]; |
| } |
| |
| UIScrollView *oldTrackingScrollView = _trackingScrollView; |
| |
| CGFloat stashedHeight = CGRectGetHeight(self.bounds); |
| if (_trackingInfo != nil) { |
| _trackingInfo.stashedHeight = stashedHeight; |
| _trackingInfo.stashedHeightIsValid = YES; |
| } |
| |
| BOOL wasTrackingScrollView = _trackingScrollView != nil; |
| _shifter.trackingScrollView = trackingScrollView; |
| _trackingScrollView = trackingScrollView; |
| |
| // If this header is shared by many scroll views then we leave the insets when switching the |
| // tracking scroll view. |
| if (!_sharedWithManyScrollViews && wasTrackingScrollView) { |
| [self fhv_removeInsetsFromScrollView:oldTrackingScrollView]; |
| } |
| |
| _shiftAccumulatorLastContentOffsetIsValid = NO; |
| _shiftAccumulatorLastContentOffset = _trackingScrollView.contentOffset; |
| _shiftAccumulatorDeltaY = 0; |
| |
| _trackingInfo = [_trackedScrollViews objectForKey:_trackingScrollView]; |
| _trackingInfo.stashedHeightIsValid = NO; |
| _trackingInfo.stashedHeight = 0; |
| |
| [self fhv_enforceInsetsForScrollView:_trackingScrollView]; |
| |
| if (self.observesTrackingScrollViewScrollEvents) { |
| [self fhv_startObservingContentOffset]; |
| } |
| |
| BOOL shouldAnimate = NO; |
| |
| if (self.sharedWithManyScrollViews && wasTrackingScrollView) { |
| // What's our expected height now that we've changed the tracking scroll view? |
| CGFloat headerHeight = -[self fhv_contentOffsetWithoutInjectedTopInset]; |
| headerHeight = MAX(self.minMaxHeight.minimumHeightWithTopSafeArea, |
| MIN(self.minMaxHeight.maximumHeightWithTopSafeArea, headerHeight)); |
| |
| // How much will our height change if we do nothing right now? |
| const CGFloat heightDelta = stashedHeight - headerHeight; |
| |
| // When canAlwaysExpandToMaximumHeight is enabled our header's height no longer directly |
| // correlates to the content offset - it's also augmented by the shift accumulator. In order to |
| // keep the header's height constant when changing the tracking scroll view, we need to adjust |
| // the shift accumulator accordingly. |
| if (self.canAlwaysExpandToMaximumHeight) { |
| // Cap the accumulator to ensure it's valid. |
| CGFloat accumulatorMin; |
| if (headerHeight > self.minMaxHeight.minimumHeightWithTopSafeArea + kHeightEpsilon) { |
| // We're attached to the content, so don't allow any height accumulation. |
| accumulatorMin = 0; |
| } else { |
| accumulatorMin = [self fhv_accumulatorMin]; |
| } |
| // Adjust the accumulator so that our height won't change and cap it to the possible range. |
| CGFloat desiredShiftAccumulatorValue = |
| MAX(accumulatorMin, MIN([self fhv_accumulatorMax], _shiftAccumulator - heightDelta)); |
| if (_shiftAccumulator != desiredShiftAccumulatorValue) { |
| _shiftAccumulator = desiredShiftAccumulatorValue; |
| } |
| } |
| |
| CGPoint offset = self.trackingScrollView.contentOffset; |
| BOOL trackingScrollViewIsUITableView = |
| [self.trackingScrollView isKindOfClass:[UITableView class]]; |
| |
| // Are we moving to content that requires the header to be expanded? |
| if (headerHeight > stashedHeight) { |
| // If so, shift the content up so that the header matches our current height. |
| offset.y -= heightDelta; |
| } else if (headerHeight < stashedHeight) { |
| // We're about to shrink the header - this is the only case where we want to animate the |
| // header's height change. |
| shouldAnimate = YES; |
| } |
| |
| if (!CGPointEqualToPoint(self.trackingScrollView.contentOffset, offset)) { |
| self.trackingScrollView.contentOffset = offset; |
| } |
| |
| if (trackingScrollViewIsUITableView) { |
| _trackingInfo.shouldIgnoreNextSafeAreaAdjustment = YES; |
| } |
| } |
| |
| BOOL animated = wasTrackingScrollView && shouldAnimate; |
| |
| void (^animations)(void) = ^{ |
| if ([self.animationDelegate respondsToSelector:@selector(flexibleHeaderView: |
| didChangeTrackingScrollViewAnimated:)]) { |
| [self.animationDelegate flexibleHeaderView:self didChangeTrackingScrollViewAnimated:animated]; |
| } |
| [self fhv_updateLayout]; |
| }; |
| void (^completion)(BOOL) = ^(BOOL finished) { |
| if (!finished) { |
| return; |
| } |
| |
| // When the tracking scroll view is cleared we need a shadow update. |
| if (!self.trackingScrollView) { |
| [self fhv_accumulatorDidChange]; |
| } |
| }; |
| |
| if (animated) { |
| void (^completionWithDelegate)(BOOL) = ^(BOOL finished) { |
| if ([self.animationDelegate |
| respondsToSelector:@selector |
| (flexibleHeaderViewChangeTrackingScrollViewAnimationDidComplete:)]) { |
| [self.animationDelegate |
| flexibleHeaderViewChangeTrackingScrollViewAnimationDidComplete:self]; |
| } |
| completion(finished); |
| }; |
| if (self.allowShadowLayerFrameAnimationsWhenChangingTrackingScrollView) { |
| [self animateWithAnimations:animations completion:completionWithDelegate]; |
| } else { |
| [UIView animateWithDuration:kTrackingScrollViewDidChangeAnimationDuration |
| animations:animations |
| completion:completionWithDelegate]; |
| } |
| } else { |
| animations(); |
| completion(YES); |
| } |
| } |
| |
| - (void)trackingScrollViewDidScroll { |
| NSAssert(!self.observesTrackingScrollViewScrollEvents, |
| @"Do not manually forward tracking scroll view events when" |
| @" observesTrackingScrollViewScrollEvents is enabled."); |
| |
| [self fhv_contentOffsetDidChange]; |
| } |
| |
| - (void)trackingScrollViewDidChangeAdjustedContentInset:(UIScrollView *)trackingScrollView { |
| [self fhv_adjustTrackingScrollViewInsetsForTrackingScrollView:trackingScrollView]; |
| } |
| |
| - (void)trackingScrollViewDidEndDraggingWillDecelerate:(BOOL)willDecelerate { |
| NSAssert(!self.observesTrackingScrollViewScrollEvents, |
| @"Do not manually forward tracking scroll view events when" |
| @" observesTrackingScrollViewScrollEvents is enabled."); |
| |
| if (self.canAlwaysExpandToMaximumHeight) { |
| if (![self fhv_canShiftOffscreen] && [self fhv_isPartiallyShifted]) { |
| _wantsToBeHidden = NO; |
| } |
| if (!willDecelerate && ([self fhv_isPartiallyShifted] || [self fhv_isPartiallyExpanded])) { |
| [self fhv_startDisplayLink]; |
| } |
| } else { |
| if (![self fhv_canShiftOffscreen]) { |
| _wantsToBeHidden = NO; |
| } |
| if (!willDecelerate && [self fhv_isPartiallyShifted]) { |
| [self fhv_startDisplayLink]; |
| } |
| } |
| _didDecelerate = willDecelerate; |
| } |
| |
| - (void)trackingScrollViewDidEndDecelerating { |
| NSAssert(!self.observesTrackingScrollViewScrollEvents, |
| @"Do not manually forward tracking scroll view events when" |
| @" observesTrackingScrollViewScrollEvents is enabled."); |
| |
| // This event can be invoked after two different forms of user interaction: |
| // |
| // 1. When the tracking scroll view was tossed and then came to rest. |
| // 2. When the tracking scroll view was tossed, then grabbed and released (without another toss). |
| // |
| // We only want to react to the first type of interaction. _didDecelerate will only be true in the |
| // first case. |
| if (!_didDecelerate) { |
| return; |
| } |
| if ([self fhv_isPartiallyShifted]) { |
| _wantsToBeHidden = |
| (_shiftAccumulator >= (1 - kMinimumVisibleProportion) * [self fhv_accumulatorMax]); |
| [self fhv_startDisplayLink]; |
| } else if ([self fhv_isPartiallyExpanded]) { |
| _wantsToBeHidden = |
| (_shiftAccumulator >= (1 - kMinimumVisibleProportion) * [self fhv_accumulatorMin]); |
| [self fhv_startDisplayLink]; |
| } |
| } |
| |
| - (BOOL)prefersStatusBarHidden { |
| return _statusBarShifter.prefersStatusBarHidden; |
| } |
| |
| - (void)setStatusBarHintCanOverlapHeader:(BOOL)statusBarHintCanOverlapHeader { |
| if (_statusBarHintCanOverlapHeader == statusBarHintCanOverlapHeader) { |
| return; |
| } |
| _statusBarHintCanOverlapHeader = statusBarHintCanOverlapHeader; |
| |
| _statusBarShifter.enabled = [self fhv_shouldAllowShifting]; |
| |
| [self fhv_startDisplayLink]; |
| } |
| |
| - (void)setShiftBehavior:(MDCFlexibleHeaderShiftBehavior)shiftBehavior { |
| NSAssert((self.observesTrackingScrollViewScrollEvents && |
| (shiftBehavior == MDCFlexibleHeaderShiftBehaviorDisabled || |
| shiftBehavior == MDCFlexibleHeaderShiftBehaviorHideable)) || |
| !self.observesTrackingScrollViewScrollEvents, |
| @"Flexible Header shift behavior must be disabled before content offset observation is" |
| @" enabled."); |
| |
| shiftBehavior = [MDCFlexibleHeaderShifter behaviorForCurrentContextFromBehavior:shiftBehavior]; |
| |
| if (_shifter.behavior == shiftBehavior) { |
| return; |
| } |
| BOOL needsShiftOnScreen = (_shifter.behavior != MDCFlexibleHeaderShiftBehaviorDisabled && |
| shiftBehavior == MDCFlexibleHeaderShiftBehaviorDisabled); |
| _shifter.behavior = shiftBehavior; |
| |
| _statusBarShifter.enabled = [self fhv_shouldAllowShifting]; |
| |
| if (needsShiftOnScreen) { |
| _wantsToBeHidden = NO; |
| [self fhv_startDisplayLink]; |
| } |
| } |
| |
| - (MDCFlexibleHeaderShiftBehavior)shiftBehavior { |
| return _shifter.behavior; |
| } |
| |
| - (void)setBehavior:(MDCFlexibleHeaderShiftBehavior)behavior { |
| self.shiftBehavior = behavior; |
| } |
| |
| - (MDCFlexibleHeaderShiftBehavior)behavior { |
| return self.shiftBehavior; |
| } |
| |
| - (void)changeContentInsets:(MDCFlexibleHeaderChangeContentInsetsBlock)block { |
| if (!block) { |
| return; |
| } |
| _contentInsetsAreChanging = YES; |
| UIEdgeInsets previousInsets = _trackingScrollView.contentInset; |
| block(); |
| CGFloat delta = _trackingScrollView.contentInset.top - previousInsets.top; |
| CGPoint contentOffset = _trackingScrollView.contentOffset; |
| contentOffset.y -= delta; // Keeps the scroll view offset from jumping. |
| [self fhv_setContentOffset:contentOffset forTrackingScrollView:_trackingScrollView]; |
| _contentInsetsAreChanging = NO; |
| } |
| |
| - (void)interfaceOrientationWillChange { |
| NSAssert(!_interfaceOrientationIsChanging, @"Call to %@::%@ not matched by a call to %@.", |
| NSStringFromClass([self class]), NSStringFromSelector(_cmd), |
| NSStringFromSelector(@selector(interfaceOrientationDidChange))); |
| |
| _interfaceOrientationIsChanging = YES; |
| |
| [_statusBarShifter interfaceOrientationWillChange]; |
| } |
| |
| - (void)interfaceOrientationIsChanging { |
| NSAssert(_interfaceOrientationIsChanging, @"Call to %@::%@ not matched by a call to %@.", |
| NSStringFromClass([self class]), NSStringFromSelector(_cmd), |
| NSStringFromSelector(@selector(interfaceOrientationWillChange))); |
| [_topSafeArea safeAreaInsetsDidChange]; |
| [self fhv_updateLayout]; |
| } |
| |
| - (void)interfaceOrientationDidChange { |
| NSAssert(_interfaceOrientationIsChanging, @"Call to %@::%@ not matched by a call to %@.", |
| NSStringFromClass([self class]), NSStringFromSelector(_cmd), |
| NSStringFromSelector(@selector(interfaceOrientationWillChange))); |
| |
| _interfaceOrientationIsChanging = NO; |
| |
| // Ignore any content offset delta that occured as a result of any orientation change. |
| _shiftAccumulatorLastContentOffset = [self fhv_boundedContentOffset]; |
| |
| [self fhv_updateLayout]; |
| |
| [_statusBarShifter interfaceOrientationDidChange]; |
| } |
| |
| - (void)viewWillTransitionToSize:(__unused CGSize)size |
| withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator { |
| [self interfaceOrientationWillChange]; |
| [coordinator |
| animateAlongsideTransition:^( |
| __unused id<UIViewControllerTransitionCoordinatorContext> context) { |
| [self interfaceOrientationIsChanging]; |
| } |
| completion:^(__unused id<UIViewControllerTransitionCoordinatorContext> context) { |
| [self interfaceOrientationDidChange]; |
| }]; |
| } |
| |
| - (void)hideViewWhenShifted:(UIView *)view { |
| [_viewsToHideWhenShifted addObject:view]; |
| } |
| |
| - (void)stopHidingViewWhenShifted:(UIView *)view { |
| [_viewsToHideWhenShifted removeObject:view]; |
| } |
| |
| - (void)forwardTouchEventsForView:(UIView *)view { |
| [_forwardingViews addObject:view]; |
| } |
| |
| - (void)stopForwardingTouchEventsForView:(UIView *)view { |
| [_forwardingViews removeObject:view]; |
| } |
| |
| - (CGFloat)minimumHeight { |
| return self.minMaxHeight.minimumHeight; |
| } |
| |
| - (void)setMinimumHeight:(CGFloat)minimumHeight { |
| self.minMaxHeight.minimumHeight = minimumHeight; |
| } |
| |
| - (CGFloat)maximumHeight { |
| return self.minMaxHeight.maximumHeight; |
| } |
| |
| - (void)setMaximumHeight:(CGFloat)maximumHeight { |
| self.minMaxHeight.maximumHeight = maximumHeight; |
| } |
| |
| - (BOOL)minMaxHeightIncludesSafeArea { |
| return self.minMaxHeight.minMaxHeightIncludesSafeArea; |
| } |
| |
| - (void)setMinMaxHeightIncludesSafeArea:(BOOL)minMaxHeightIncludesSafeArea { |
| self.minMaxHeight.minMaxHeightIncludesSafeArea = minMaxHeightIncludesSafeArea; |
| } |
| |
| - (void)setInFrontOfInfiniteContent:(BOOL)inFrontOfInfiniteContent { |
| if (_inFrontOfInfiniteContent == inFrontOfInfiniteContent) { |
| return; |
| } |
| _inFrontOfInfiniteContent = inFrontOfInfiniteContent; |
| |
| if (!_trackingScrollView) { |
| // Change the opacity directly as fhv_updateLayout is a no-op when _trackingScrollView is nil. |
| self.layer.shadowOpacity = _inFrontOfInfiniteContent ? _visibleShadowOpacity : 0; |
| } else { |
| [self fhv_updateLayout]; |
| } |
| } |
| |
| - (BOOL)trackingScrollViewWillEndDraggingWithVelocity:(__unused CGPoint)velocity |
| targetContentOffset:(inout CGPoint *)targetContentOffset { |
| NSAssert(!self.observesTrackingScrollViewScrollEvents, |
| @"Do not manually forward tracking scroll view events when" |
| @" observesTrackingScrollViewScrollEvents is enabled."); |
| |
| #if DEBUG |
| _didAdjustTargetContentOffset = YES; |
| #endif |
| |
| if ([self fhv_canShiftOffscreen]) { |
| CGPoint target = *targetContentOffset; |
| |
| CGFloat offsetTargetY = target.y + [self fhv_rawTopContentInset]; |
| CGFloat flexHeight = -offsetTargetY; |
| |
| if ([self fhv_canShiftOffscreen] && |
| (0 < flexHeight && flexHeight < self.minMaxHeight.minimumHeightWithTopSafeArea)) { |
| // Don't allow the header to be partially visible. |
| if (_wantsToBeHidden) { |
| target.y = -[self fhv_rawTopContentInset]; |
| } else { |
| target.y = -self.minMaxHeight.minimumHeightWithTopSafeArea - [self fhv_rawTopContentInset]; |
| } |
| *targetContentOffset = target; |
| return YES; |
| } |
| } |
| if (self.canAlwaysExpandToMaximumHeight && [self fhv_isPartiallyExpanded]) { |
| CGPoint target = *targetContentOffset; |
| |
| // Don't allow the header to be partially expanded. |
| if (_wantsToBeHidden) { |
| target.y -= _shiftAccumulator; |
| } else { |
| target.y += ([self fhv_accumulatorMin] - _shiftAccumulator); |
| } |
| *targetContentOffset = target; |
| return YES; |
| } |
| |
| return NO; |
| } |
| |
| - (void)trackingScrollWillChangeToScrollView:(UIScrollView *)scrollView { |
| MDCFlexibleHeaderScrollViewInfo *info = [_trackedScrollViews objectForKey:scrollView]; |
| if (!info) { |
| CGFloat topInsetDelta = [self fhv_enforceInsetsForScrollView:scrollView]; |
| info = [_trackedScrollViews objectForKey:scrollView]; |
| |
| CGPoint offset = scrollView.contentOffset; |
| offset.y -= topInsetDelta; |
| scrollView.contentOffset = offset; |
| } |
| |
| [self fhv_matchHeightWithScrollView:scrollView]; |
| } |
| |
| - (void)shiftHeaderOnScreenAnimated:(BOOL)animated { |
| _wantsToBeHidden = NO; |
| |
| if (animated) { |
| [self fhv_startDisplayLink]; |
| } else { |
| // Remove any offscreen accumulation. |
| _shiftAccumulator = 0; |
| [self fhv_commitAccumulatorToFrame]; |
| } |
| } |
| |
| - (void)shiftHeaderOffScreenAnimated:(BOOL)animated { |
| _wantsToBeHidden = YES; |
| |
| if (animated) { |
| [self fhv_startDisplayLink]; |
| } else { |
| // Add offscreen accumulation equal to this header view's size. |
| _shiftAccumulator = self.fhv_accumulatorMax; |
| [self fhv_commitAccumulatorToFrame]; |
| } |
| } |
| |
| - (void)setContentIsTranslucent:(BOOL)contentIsTranslucent { |
| _contentIsTranslucent = contentIsTranslucent; |
| |
| // Translucent content means that the status bar shifter should not use snapshotting. Otherwise, |
| // stale visual content under the status bar region may be snapshotted. |
| _statusBarShifter.snapshottingEnabled = !contentIsTranslucent; |
| } |
| |
| - (void)animateWithAnimations:(void (^_Nonnull)(void))animations |
| completion:(void (^_Nullable)(BOOL))completion { |
| NSTimeInterval duration = kTrackingScrollViewDidChangeAnimationDuration; |
| |
| // Note: this should match the easing curve passed to UIView's animateWithDuration:... below. |
| CAMediaTimingFunction *timingFunction = |
| [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; |
| [UIView animateWithDuration:duration |
| delay:0.f |
| // Note: this should match the timing function above. |
| options:UIViewAnimationOptionCurveEaseInOut |
| animations:^{ |
| self->_isAnimatingLayoutUpdate = YES; |
| |
| [CATransaction begin]; |
| #if TARGET_IPHONE_SIMULATOR |
| [CATransaction setAnimationDuration:duration * [self fhv_dragCoefficient]]; |
| #else |
| // clang-format is trying to indent this line too far to the left. |
| // clang-format off |
| [CATransaction setAnimationDuration:duration]; |
| // clang-format on |
| #endif |
| [CATransaction setAnimationTimingFunction:timingFunction]; |
| |
| animations(); |
| |
| [self fhv_updateLayout]; |
| |
| // Force any layout changes to be committed during this animation block. |
| [self layoutIfNeeded]; |
| |
| [CATransaction commit]; |
| |
| self->_isAnimatingLayoutUpdate = NO; |
| } |
| completion:completion]; |
| } |
| |
| - (BOOL)isShiftedOffscreen { |
| return _wantsToBeHidden || [self fhv_isFullyShifted]; |
| } |
| |
| #pragma mark - MDCElevation |
| |
| - (void)setElevation:(MDCShadowElevation)elevation { |
| if (MDCCGFloatEqual(elevation, _elevation)) { |
| return; |
| } |
| |
| _elevation = elevation; |
| [self mdc_elevationDidChange]; |
| } |
| |
| - (CGFloat)mdc_currentElevation { |
| return self.elevation; |
| } |
| |
| @end |
| |
| @implementation MDCFlexibleHeaderScrollViewInfo |
| @end |