From a8d3794de3dff0163126726f8b4dd997fa1fec68 Mon Sep 17 00:00:00 2001 From: Yarden Eitan Date: Tue, 30 Oct 2018 13:27:17 +0200 Subject: [PATCH] [NavigationDrawer] Added a state system to the Nav Drawer (#5520) Context: To allow us to create APIs for our users to set different appearances based on the different states the drawer can be in, we need to create an initial state system. The Problem: Without defining a state for our drawer, we won't be able to differentiate between different presentations the drawer may be in, and then alter the drawer's appearance effectively. The Fix: Provide a state enum as part of MDCBottomDrawerController, that is read only, and is set using a delegate that is initially set by the internal implementation. Testing: Unit Tests + Tested on an iPhone X and iPhone 7 with smaller and bigger preferredContentSize to imitate different states. Related Bugs: Closes #5524 --- .../MDCBottomDrawerPresentationController.h | 24 ++++ .../MDCBottomDrawerPresentationController.m | 18 ++- .../src/MDCBottomDrawerState.h | 32 +++++ .../src/MDCBottomDrawerViewController.h | 10 +- .../src/MDCBottomDrawerViewController.m | 6 + .../MDCBottomDrawerContainerViewController.h | 29 +++++ .../MDCBottomDrawerContainerViewController.m | 28 +++++ .../unit/MDCNavigationDrawerScrollViewTests.m | 111 ++++++++++++++++++ 8 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 components/NavigationDrawer/src/MDCBottomDrawerState.h diff --git a/components/NavigationDrawer/src/MDCBottomDrawerPresentationController.h b/components/NavigationDrawer/src/MDCBottomDrawerPresentationController.h index e0a318929..76118b32e 100644 --- a/components/NavigationDrawer/src/MDCBottomDrawerPresentationController.h +++ b/components/NavigationDrawer/src/MDCBottomDrawerPresentationController.h @@ -13,6 +13,25 @@ // limitations under the License. #import +#import "MDCBottomDrawerState.h" + +@class MDCBottomDrawerPresentationController; + +/** + Delegate for MDCBottomSheetPresentationController. + */ +@protocol MDCBottomDrawerPresentationControllerDelegate +/** + This method is called when the bottom drawer will change its presented state to one of the + MDCBottomDrawerState states. + + @param presentationController presentation controller of the bottom drawer + @param drawerState the drawer's state + */ +- (void)bottomDrawerWillChangeState: + (nonnull MDCBottomDrawerPresentationController *)presentationController + drawerState:(MDCBottomDrawerState)drawerState; +@end /** The presentation controller to use for presenting an MDC bottom drawer. @@ -27,4 +46,9 @@ */ @property(nonatomic, weak, nullable) UIScrollView *trackingScrollView; +/** + Delegate to tell the presenter when the drawer will change state. + */ +@property(nonatomic, weak, nullable) id delegate; + @end diff --git a/components/NavigationDrawer/src/MDCBottomDrawerPresentationController.m b/components/NavigationDrawer/src/MDCBottomDrawerPresentationController.m index fdd0168f2..f935140bd 100644 --- a/components/NavigationDrawer/src/MDCBottomDrawerPresentationController.m +++ b/components/NavigationDrawer/src/MDCBottomDrawerPresentationController.m @@ -21,7 +21,8 @@ static UIColor *DrawerOverlayBackgroundColor(void) { return [UIColor colorWithWhite:0 alpha:0.4f]; } -@interface MDCBottomDrawerPresentationController () +@interface MDCBottomDrawerPresentationController () /** A semi-transparent scrim view that darkens the visible main view when the drawer is displayed. @@ -37,6 +38,8 @@ static UIColor *DrawerOverlayBackgroundColor(void) { @implementation MDCBottomDrawerPresentationController +@synthesize delegate; + - (UIView *)presentedView { if ([self.presentedViewController isKindOfClass:[MDCBottomDrawerViewController class]]) { return super.presentedView; @@ -63,11 +66,13 @@ static UIColor *DrawerOverlayBackgroundColor(void) { bottomDrawerViewController.contentViewController; bottomDrawerContainerViewController.headerViewController = bottomDrawerViewController.headerViewController; + self.delegate = bottomDrawerViewController; } else { bottomDrawerContainerViewController.contentViewController = self.presentedViewController; } bottomDrawerContainerViewController.animatingPresentation = YES; self.bottomDrawerContainerViewController = bottomDrawerContainerViewController; + self.bottomDrawerContainerViewController.delegate = self; self.scrimView = [[UIView alloc] initWithFrame:self.containerView.bounds]; self.scrimView.backgroundColor = DrawerOverlayBackgroundColor(); @@ -153,4 +158,15 @@ static UIColor *DrawerOverlayBackgroundColor(void) { shouldReceiveTouch:touch]; } +#pragma mark - MDCBottomDrawerContainerViewControllerDelegate + +- (void)bottomDrawerContainerViewControllerWillChangeState: + (MDCBottomDrawerContainerViewController *)containerViewController + drawerState:(MDCBottomDrawerState)drawerState { + id strongDelegate = self.delegate; + if ([strongDelegate respondsToSelector:@selector(bottomDrawerWillChangeState:drawerState:)]) { + [strongDelegate bottomDrawerWillChangeState:self drawerState:drawerState]; + } +} + @end diff --git a/components/NavigationDrawer/src/MDCBottomDrawerState.h b/components/NavigationDrawer/src/MDCBottomDrawerState.h new file mode 100644 index 000000000..040f22c20 --- /dev/null +++ b/components/NavigationDrawer/src/MDCBottomDrawerState.h @@ -0,0 +1,32 @@ +// Copyright 2018-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 + +/** + The MDCBottomDrawerState enum provides the different possible states the bottom drawer can be in. + There are 2 different states for the bottom drawer: + + - MDCBottomDrawerStateCollapsed: This state is reached when the bottom drawer is collapsed + (can be expanded to present more content), but is not taking up the entire screen. + - MDCBottomDrawerStateExpanded: This state is reached when the bottom drawer is fully expanded + (displaying the entire content), but is not taking up the entire screen. + - MDCBottomDrawerStateFullScreen: This state is reached when the bottom drawer + is in full screen. + */ +typedef NS_ENUM(NSUInteger, MDCBottomDrawerState) { + MDCBottomDrawerStateCollapsed = 0, + MDCBottomDrawerStateExpanded = 1, + MDCBottomDrawerStateFullScreen = 2, +}; diff --git a/components/NavigationDrawer/src/MDCBottomDrawerViewController.h b/components/NavigationDrawer/src/MDCBottomDrawerViewController.h index 4c7231280..0c775504b 100644 --- a/components/NavigationDrawer/src/MDCBottomDrawerViewController.h +++ b/components/NavigationDrawer/src/MDCBottomDrawerViewController.h @@ -13,13 +13,16 @@ // limitations under the License. #import +#import "MDCBottomDrawerPresentationController.h" +#import "MDCBottomDrawerState.h" @protocol MDCBottomDrawerHeader; /** View controller for containing a Google Material bottom drawer. */ -@interface MDCBottomDrawerViewController : UIViewController +@interface MDCBottomDrawerViewController + : UIViewController /** The main content displayed by the drawer. @@ -41,4 +44,9 @@ */ @property(nonatomic, weak, nullable) UIScrollView *trackingScrollView; +/** + The current state of the bottom drawer. + */ +@property(nonatomic, readonly) MDCBottomDrawerState drawerState; + @end diff --git a/components/NavigationDrawer/src/MDCBottomDrawerViewController.m b/components/NavigationDrawer/src/MDCBottomDrawerViewController.m index 81ca06c16..5804b4c2f 100644 --- a/components/NavigationDrawer/src/MDCBottomDrawerViewController.m +++ b/components/NavigationDrawer/src/MDCBottomDrawerViewController.m @@ -65,4 +65,10 @@ return YES; } +- (void)bottomDrawerWillChangeState: + (nonnull MDCBottomDrawerPresentationController *)presentationController + drawerState:(MDCBottomDrawerState)drawerState { + _drawerState = drawerState; +} + @end diff --git a/components/NavigationDrawer/src/private/MDCBottomDrawerContainerViewController.h b/components/NavigationDrawer/src/private/MDCBottomDrawerContainerViewController.h index c3837a482..924cdc321 100644 --- a/components/NavigationDrawer/src/private/MDCBottomDrawerContainerViewController.h +++ b/components/NavigationDrawer/src/private/MDCBottomDrawerContainerViewController.h @@ -13,8 +13,27 @@ // limitations under the License. #import +#import "MDCBottomDrawerState.h" +@class MDCBottomDrawerContainerViewController; @protocol MDCBottomDrawerHeader; +@protocol MDCBottomDrawerContainerViewControllerDelegate; + +/** + Delegate for MDCBottomDrawerContainerViewController. + */ +@protocol MDCBottomDrawerContainerViewControllerDelegate +/** + This method is called when the bottom drawer will change its presented state to one of the + MDCBottomDrawerState states. + + @param containerViewController the container view controller of the bottom drawer. + @param drawerState the drawer's state. + */ +- (void)bottomDrawerContainerViewControllerWillChangeState: + (nonnull MDCBottomDrawerContainerViewController *)containerViewController + drawerState:(MDCBottomDrawerState)drawerState; +@end /** View controller for containing a Google Material bottom drawer. Used internally only. @@ -61,4 +80,14 @@ // Whether the drawer is currently animating its presentation. @property(nonatomic) BOOL animatingPresentation; +/** + Delegate to tell the presentation controller when the drawer will change state. + */ +@property(nonatomic, weak, nullable) id delegate; + +/** + The current state of the bottom drawer. + */ +@property(nonatomic, readonly) MDCBottomDrawerState drawerState; + @end diff --git a/components/NavigationDrawer/src/private/MDCBottomDrawerContainerViewController.m b/components/NavigationDrawer/src/private/MDCBottomDrawerContainerViewController.m index 43fba7d87..2b3039d7f 100644 --- a/components/NavigationDrawer/src/private/MDCBottomDrawerContainerViewController.m +++ b/components/NavigationDrawer/src/private/MDCBottomDrawerContainerViewController.m @@ -137,6 +137,9 @@ static UIColor *DrawerShadowColor(void) { // The top header bottom shadow layer. @property(nonatomic) MDCShadowLayer *headerShadowLayer; +// The current bottom drawer state. +@property(nonatomic) MDCBottomDrawerState drawerState; + @end @implementation MDCBottomDrawerContainerViewController { @@ -162,6 +165,7 @@ static UIColor *DrawerShadowColor(void) { _maskLayer = [[MDCBottomDrawerHeaderMask alloc] initWithMaximumCornerRadius:kDefaultHeaderCornerRadius minimumCornerRadius:0]; + _drawerState = MDCBottomDrawerStateCollapsed; } return self; } @@ -319,6 +323,22 @@ static UIColor *DrawerShadowColor(void) { [self.scrollView removeObserver:self forKeyPath:kContentOffsetKeyPath]; } +- (void)setDrawerState:(MDCBottomDrawerState)drawerState { + if (drawerState != _drawerState) { + _drawerState = drawerState; + [self.delegate bottomDrawerContainerViewControllerWillChangeState:self drawerState:drawerState]; + } +} + +- (void)updateDrawerState:(CGFloat)transitionPercentage { + if (transitionPercentage >= 1.f - kEpsilon) { + self.drawerState = self.contentReachesFullscreen ? MDCBottomDrawerStateFullScreen + : MDCBottomDrawerStateExpanded; + } else { + self.drawerState = MDCBottomDrawerStateCollapsed; + } +} + #pragma mark UIViewController - (void)viewDidLoad { @@ -475,6 +495,7 @@ static UIColor *DrawerShadowColor(void) { distance:self.headerAnimationDistance]; CGFloat headerTransitionToTop = contentOffset.y >= self.transitionCompleteContentOffset ? 1.f : transitionPercentage; + [self updateDrawerState:transitionPercentage]; [_maskLayer animateWithPercentage:1.f - transitionPercentage]; self.currentlyFullscreen = self.contentReachesFullscreen && headerTransitionToTop >= 1.f; CGFloat fullscreenHeaderHeight = @@ -669,6 +690,13 @@ static UIColor *DrawerShadowColor(void) { CGFloat scrollingDistance = _contentHeaderTopInset + contentHeaderHeight + contentHeight; _contentHeightSurplus = scrollingDistance - containerHeight; + if ([self shouldPresentFullScreen]) { + self.drawerState = MDCBottomDrawerStateFullScreen; + } else if (_contentHeightSurplus <= 0) { + self.drawerState = MDCBottomDrawerStateExpanded; + } else { + self.drawerState = MDCBottomDrawerStateCollapsed; + } if (addedContentHeight < kEpsilon && (_contentHeaderTopInset > _contentHeightSurplus) && (_contentHeaderTopInset - _contentHeightSurplus < self.addedContentHeightThreshold)) { CGFloat addedContentheight = _contentHeaderTopInset - _contentHeightSurplus; diff --git a/components/NavigationDrawer/tests/unit/MDCNavigationDrawerScrollViewTests.m b/components/NavigationDrawer/tests/unit/MDCNavigationDrawerScrollViewTests.m index 27ebce16d..cfb0ec160 100644 --- a/components/NavigationDrawer/tests/unit/MDCNavigationDrawerScrollViewTests.m +++ b/components/NavigationDrawer/tests/unit/MDCNavigationDrawerScrollViewTests.m @@ -17,6 +17,27 @@ #import "../../src/private/MDCBottomDrawerContainerViewController.h" #import "MDCNavigationDrawerFakes.h" +@interface MDCBottomDrawerDelegateTest + : UIViewController +@property(nonatomic, assign) BOOL delegateWasCalled; +@end + +@implementation MDCBottomDrawerDelegateTest + +- (instancetype)init { + self = [super init]; + if (self) { + _delegateWasCalled = NO; + } + return self; +} +- (void)bottomDrawerWillChangeState:(MDCBottomDrawerPresentationController *)presentationController + drawerState:(MDCBottomDrawerState)drawerState { + _delegateWasCalled = YES; +} + +@end + @interface MDCBottomDrawerContainerViewController (ScrollViewTests) @property(nonatomic) BOOL scrollViewObserved; @@ -27,13 +48,25 @@ @property(nonatomic, readonly) CGRect presentingViewBounds; @property(nonatomic, readonly) CGFloat contentHeightSurplus; @property(nonatomic, readonly) BOOL contentScrollsToReveal; +@property(nonatomic) MDCBottomDrawerState drawerState; +@property(nullable, nonatomic, readonly) UIPresentationController *presentationController; - (void)cacheLayoutCalculations; +- (void)updateDrawerState:(CGFloat)transitionPercentage; +@end +@interface MDCBottomDrawerPresentationController (ScrollViewTests) < + MDCBottomDrawerContainerViewControllerDelegate> +@property(nonatomic) MDCBottomDrawerContainerViewController *bottomDrawerContainerViewController; +@property(nonatomic, weak, nullable) id delegate; @end @interface MDCNavigationDrawerScrollViewTests : XCTestCase @property(nonatomic, strong, nullable) UIScrollView *fakeScrollView; @property(nonatomic, strong, nullable) MDCBottomDrawerContainerViewController *fakeBottomDrawer; +@property(nonatomic, strong, nullable) MDCBottomDrawerViewController *drawerViewController; +@property(nonatomic, strong, nullable) + MDCBottomDrawerPresentationController *presentationController; +@property(nonatomic, strong, nullable) MDCBottomDrawerDelegateTest *delegateTest; @end @implementation MDCNavigationDrawerScrollViewTests @@ -48,6 +81,12 @@ _fakeBottomDrawer = [[MDCBottomDrawerContainerViewController alloc] initWithOriginalPresentingViewController:fakeViewController trackingScrollView:_fakeScrollView]; + _drawerViewController = [[MDCBottomDrawerViewController alloc] init]; + _drawerViewController.contentViewController = fakeViewController; + _presentationController = [[MDCBottomDrawerPresentationController alloc] + initWithPresentedViewController:_drawerViewController + presentingViewController:nil]; + _delegateTest = [[MDCBottomDrawerDelegateTest alloc] init]; } - (void)tearDown { @@ -317,4 +356,76 @@ XCTAssertTrue(self.fakeBottomDrawer.contentScrollsToReveal); } +- (void)testBottomDrawerStateCollapsed { + CGSize fakePreferredContentSize = CGSizeMake(200, 1000); + MDCNavigationDrawerFakeHeaderViewController *fakeHeader = + [[MDCNavigationDrawerFakeHeaderViewController alloc] init]; + fakeHeader.preferredContentSize = fakePreferredContentSize; + self.fakeBottomDrawer.headerViewController = fakeHeader; + self.fakeBottomDrawer.contentViewController = + [[MDCNavigationDrawerFakeTableViewController alloc] init]; + + // When + self.fakeBottomDrawer.contentViewController.preferredContentSize = CGSizeMake(200, 1500); + [self.fakeBottomDrawer cacheLayoutCalculations]; + + // Then + XCTAssertEqual(self.fakeBottomDrawer.drawerState, MDCBottomDrawerStateCollapsed); +} + +- (void)testBottomDrawerStateExpanded { + CGSize fakePreferredContentSize = CGSizeMake(200, 100); + MDCNavigationDrawerFakeHeaderViewController *fakeHeader = + [[MDCNavigationDrawerFakeHeaderViewController alloc] init]; + fakeHeader.preferredContentSize = fakePreferredContentSize; + self.fakeBottomDrawer.headerViewController = fakeHeader; + self.fakeBottomDrawer.contentViewController = + [[MDCNavigationDrawerFakeTableViewController alloc] init]; + + // When + self.fakeBottomDrawer.contentViewController.preferredContentSize = CGSizeMake(200, 200); + [self.fakeBottomDrawer cacheLayoutCalculations]; + + // Then + XCTAssertEqual(self.fakeBottomDrawer.drawerState, MDCBottomDrawerStateExpanded); +} + +- (void)testBottomDrawerStateFullScreen { + CGSize fakePreferredContentSize = CGSizeMake(200, 2000); + MDCNavigationDrawerFakeHeaderViewController *fakeHeader = + [[MDCNavigationDrawerFakeHeaderViewController alloc] init]; + fakeHeader.preferredContentSize = fakePreferredContentSize; + self.fakeBottomDrawer.headerViewController = fakeHeader; + self.fakeBottomDrawer.contentViewController = + [[MDCNavigationDrawerFakeTableViewController alloc] init]; + + // When + [self.fakeBottomDrawer cacheLayoutCalculations]; + [self.fakeBottomDrawer updateDrawerState:1.f]; + + // Then + XCTAssertEqual(self.fakeBottomDrawer.drawerState, MDCBottomDrawerStateFullScreen); +} + +- (void)testBottomDrawerStateCallback { + CGSize fakePreferredContentSize = CGSizeMake(200, 1000); + MDCNavigationDrawerFakeHeaderViewController *fakeHeader = + [[MDCNavigationDrawerFakeHeaderViewController alloc] init]; + fakeHeader.preferredContentSize = fakePreferredContentSize; + self.fakeBottomDrawer.headerViewController = fakeHeader; + self.fakeBottomDrawer.contentViewController = + [[MDCNavigationDrawerFakeTableViewController alloc] init]; + + // When + self.fakeBottomDrawer.contentViewController.preferredContentSize = CGSizeMake(200, 1500); + [self.fakeBottomDrawer cacheLayoutCalculations]; + self.presentationController.delegate = self.delegateTest; + self.presentationController.bottomDrawerContainerViewController = self.fakeBottomDrawer; + self.fakeBottomDrawer.delegate = self.presentationController; + self.fakeBottomDrawer.drawerState = MDCBottomDrawerStateExpanded; + + // Then + XCTAssertEqual(self.delegateTest.delegateWasCalled, YES); +} + @end