SPStackedNav is an open source split-screen framework for iPad from Spotify, the world’s largest streaming music service provider, which is used in Spotify’s iPad App. NetEase Cloud Music iPad App also adopts a similar split-screen interaction scheme. The interaction of the framework is shown below:


How the SPStackedNav implementation interacts

use

After importing the project according to GitHub’s instructions, you are ready to build the UI framework.

  1. Create SPSideTabController. The usage of SPSideTabController is not much different from the usage of UITabController.

  2. Create the RootViewController for SPSideTabController separately and set the UITabBarItem property.

  3. Assign the corresponding RootViewController array to the viewControllers property of SPSideTabController.

  4. The Demo’s AppDelegate code looks like this:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]]; // Override point for customization after application launch. self.window.backgroundColor = [UIColor whiteColor]; // Step 1 Create SPSideTabController self.tabs = [[SPSideTabController alloc] init]; // Step 2 Create RootViewController of SPSideTabController respectively. Set UITabBarItem RootTestViewController *root1 = [RootTestViewController new]; root1.title = @"Root 1"; root1.tabBarItem.image = [UIImage imageNamed:@"114-balloon"]; RootTestViewController *root2 = [RootTestViewController new]; root2.title = @"Root 2"; root2.tabBarItem.image = [UIImage imageNamed:@"185-printer"]; root2.tabBarItem.badgeValue = @"5"; root2.tabBarItem.badgeColor = [UIColor redColor]; RootTestViewController *root3 = [RootTestViewController new]; root3.title = @"Root 3"; root3.tabBarItem.image = [UIImage imageNamed:@"114-balloon"]; / / step 3 to SPSideTabController viewControllers attribute assignment corresponding RootViewController array self. The tabs. ViewControllers = @ [ [[SPStackedNavigationController alloc] initWithRootViewController:root1], [[SPStackedNavigationController alloc] initWithRootViewController:root2], [[SPStackedNavigationController alloc] initWithRootViewController:root3] ]; self.window.rootViewController = self.tabs; [self.window makeKeyAndVisible]; return YES; } ' '5. Renderings! [Effect picture 1](http://upload-images.jianshu.io/upload_images/656644-868252e5afa69fbd.png? imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) ! [Effect 2](http://upload-images.jianshu.io/upload_images/656644-c413a01681ef5ed1.png? ImageMogr2 /auto-orient/strip% 7cImageView2/2 /w/1240) ### design! [View hierarchy](http://upload-images.jianshu.io/upload_images/656644-3ca6c07d584f5a61.png? ImageMogr2 /auto-orient/strip% 7cimageView2/2 /w/1240) The left sidebar View is an SPSideTabBar that contains several SpsideTabItemButtons. The right of the container, the View is a SPStackedNavigationScrollView the SPStackedNavigationScrollView inside contains several SPStackedPageContainer, An SPStackedPageContainer can simply be thought of as a ViewController. When we push a ViewController in RootTestViewController in the Demo project. It is equivalent to add a SPStackedNavigationScrollView SPStackedPageContainer view. SPStackedPageContainer displays content from the View property of the ViewController. ```CPP ChildTestViewController *vc = [ChildTestViewController new]; [self.stackedNavigationController pushViewController:vc animated:YES];Copy the code

SPSideTabBar and SPSideTabItemButton parse

RootTestViewController *root2 = [RootTestViewController new];
    root2.title = @"Root 2";
    root2.tabBarItem.image = [UIImage imageNamed:@"185-printer"];
    root2.tabBarItem.badgeValue = @"5";
    root2.tabBarItem.badgeColor = [UIColor redColor];
Copy the code

The Demo code sets the AppDelegate properties of the UITabBarItem, but the SPSideTabBar does not contain information about the UITabBarItem.


SPSideTabBar hierarchy


SPSideTabBar maps properties of UITabBarItem to properties of SPSideTabItemButton.


ViewDidLoad method of SPSideTabController

Look at the viewDidLoad method of the spsideTabController.m file, We can see that the _tabbar. items = validItems property set method passes the tabBarItem object array of SPSideTabController to the Items property of SPSideTabBar.

Go to the spsidetabbar. m implementation file and look at the – (void)setItems:(NSArray*)items method

- (void)setItems:(NSArray*)items {if ([items isEqual:_items]) return; self.selectedItem = nil; _items = [items copy]; for(UIView *b in _itemButtons) [b removeFromSuperview]; self.itemButtons = nil; if (_items) { NSMutableArray *itemButtons = [NSMutableArray array]; CGRect pen = CGRectMake(0, 10, 80, 70); For (UITabBarItem *item in _items) {// Convert UITabBarItem to SPSideTabItemButton UIView *b = [self buttonForItem:item withFrame:pen]; [itemButtons addObject:b]; [self addSubview:b]; pen.origin.y += pen.size.height + 10; } self.itemButtons = itemButtons; }}Copy the code

Continue tracking the viewing method

UIView *b = [self buttonForItem:item withFrame:pen];
Copy the code
// Set the SPTabBarItem frame, And return SPTabBarItem View - (UIView*)buttonForItem:(UITabBarItem*)item withFrame:(CGRect)pen {if ([item isKindOfClass:[SPTabBarItem class]] && [(SPTabBarItem*)item view]) { UIView *view = [(SPTabBarItem*)item view]; [view setFrame:pen]; return view; } SPSideTabItemButton *b = [[SPSideTabItemButton alloc] initWithFrame:pen]; Return a; return a; return a; return b; }Copy the code

Use SPSideTabBar custom View to replace system UITabBar, use SPTabBarItem custom View to replace system UITabBarItem, SPSideTabBar maps UITabBarItem property Settings to SPTabBarItem. This is a common idea for customizing tabbars.

SPStackedNavigationController parsing

SPStackedNavigationController inheritance and UIViewController, And defined and implemented a series of NavigationController related methods, in short, is to implement a NavigationController, here to re-explain the two main methods.

- (void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated activate:(BOOL)activate

- (UIViewController *)popViewControllerAnimated:(BOOL)animated;
Copy the code


The schematic SPStackedNavigationController

When SPStackedNavigationController do push operation, Is added to this View in imitation of a ScrollView SPStackedNavigationScrollView a SPStackedPageContainer View. From above in the left of the View hierarchy can see there is 2 SPStackedPageContainer SPStackedNavigationScrollView View. The View on the right in the image above demonstrates this structure.

See SPStackedNavigationController. M file – (void) pushViewController viewController: (UIViewController *) Animated :(BOOL)animated activate:(BOOL)activate implementation method

- (void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated activate:(BOOL)activate {// omit code // Add viewController to viewControllers array [self willChangeValueForKey:@"viewControllers"]; [self addChildViewController:viewController]; // Add viewController to self, If ([self isViewLoaded]) / / key steps SPStackedNavigationScrollView add a SPStackedPageContainer child View [of the self pushPageContainerWithViewController:viewController]; if (activate) [self setActiveViewController:viewController position:activePosition animated:animated]; / / call the viewController lifecycle methods [viewController didMoveToParentViewController: self]. [self didChangeValueForKey:@"viewControllers"]; }Copy the code

Then see SPStackedNavigationController. M file – (void) pushPageContainerWithViewController viewController: (UIViewController *) The method of

- (void)pushPageContainerWithViewController:(UIViewController*)viewController { CGSize size = self.view.frame.size; CGRect frame = CGRectMake(self.view.bounds.size.width, 0, 0, size.height); frame.size.width = (viewController.stackedNavigationPageSize == kStackedPageHalfSize ? kSPStackedNavigationHalfPageWidth : size.width); SPStackedPageContainer *pageC = [[SPStackedPageContainer alloc] initWithFrame:frame VC:viewController]; / / add a SPStackedPageContainer SPStackedNavigationScrollView child View [_scroll addSubview: pageC]; }Copy the code

From the code can verify we mentioned above, when SPStackedNavigationController do push operation, Is added to SPStackedNavigationScrollView this View a SPStackedPageContainer View.

We can now speculate, when SPStackedNavigationController do pop operation, Is in this View SPStackedNavigationScrollView removed a SPStackedPageContainer View.

See next SPStackedNavigationController. M file – (UIViewController *) popViewControllerAnimated: (BOOL) animated method to verify our speculation.

- (UIViewController *)popViewControllerAnimated:(BOOL)animated { UIViewController *viewController = [[self childViewControllers] lastObject]; if (! viewController) return nil; [self willChangeValueForKey:@"viewControllers"]; [viewController willMoveToParentViewController:nil]; If ([self isViewLoaded]) {// Mark SPStackedPageContainer as removed, Subsequent SPStackedNavigationScrollView will remove it SPStackedPageContainer * pageC = [_scroll containerForViewController:viewController]; pageC.markedForSuperviewRemoval = YES; } / / key steps, remove the viewController [viewController removeFromParentViewController]; [self didChangeValueForKey:@"viewControllers"]; [self setActiveViewController:[self.childViewControllers lastObject] position:SPStackedNavigationPagePositionRight animated:animated]; return viewController; }Copy the code

As we guess SPStackedNavigationController do pop operation, Is in this View SPStackedNavigationScrollView removed a SPStackedPageContainer View. And make corresponding ViewController SPStackedPageContainer a removeFromParentViewController messages.

SPStackedPageContainer parsing

SPStackedPageContainer is used to host the View of the ViewController and handle some gestures, so the concept of SPStackedPageContainer is equivalent to a split View. – (void)setVCVisible:(BOOL)VCVisible method.

- (void)setVCVisible:(BOOL)VCVisible {if (VCVisible == self.vcvisible) return; if (VCVisible) { [self.screenshot removeFromSuperview]; self.screenshot = nil; if (! self.markedForSuperviewRemoval || [_vc isViewLoaded]) { _vcContainer.backgroundColor = _vc.view.backgroundColor; _vc.view.frame = CGRectMake(0, 0, self.bounds.size.width, self.bounds.size.height); if (! Superview) // Add view [_vcContainer insertSubview:_vc.view atIndex:0]; }} else {if ([_vc. View removeFromSuperview]); }}Copy the code

SPStackedNavigationScrollView parsing

SPStackedNavigationScrollView imitation is a UIScrollView implementation View. For an in-depth understanding of UIScrollView, ObjC is recommended to understand Scroll Views, which will not be detailed here. By default, everyone can understand the relevant concepts of UIScrollView.

When using SPStackedNavigationController do Push operation, 3 times SPStackedNavigationScrollView View hierarchy is as follows.


SPStackedNavigationScrollView hierarchy

SPStackedNavigationController rootView is Container0 this View. And the Push View is Container1, Container2,Container3. The left half of the View is Container1 > Container2 from the bottom up. The half-screen View on the right is Container3. If SPStackedNavigationController to Push a View, then to the left of the screen the location of the View from the bottom up, respectively is Container1 – > Container2 – > Container3. The half-screen View on the right is Container4, where the concept of Container is synonymous with a split View. At this time SPStackedNavigationScrollView simple schematic View is as follows


SPStackedNavigationController push operation

Can be seen from the above View structure diagram, SPStackedNavigationScrollView imitations of UIScrollView mainly reflects on the UIScrollView sliding mechanism. When SPStackedNavigationController do push operation SPStackedNavigationScrollView right half screen View will slide to the left from right to left the position of the screen, The right half of the screen displays a new push View from right to left. When SPStackedNavigationController do pop operation SPStackedNavigationScrollView right half screen View will slip out of the screen from left to right, The View on the left half of the screen slides from left to right.


SPStackedNavigationController pop operation

After finished SPStackedNavigationScrollView about performance, if you still don’t understand, you can run the Demo experience SPStackedNavigationScrollView UI changes in detail. We next see SPStackedNavigationScrollView. H file, finding and UIScrollView related code.

@interface SPStackedNavigationScrollView : UIView // ...... Omit code @Property (nonatomic) CGPoint contentOffset; - (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated; - (NSRange)scrollRange; / /... Omit the code at sign endCopy the code

From SPStackedNavigationScrollView header file, we can see SPStackedNavigationScrollView inherited from the UIView. Concepts related to UIScrollView are contentOffset and scrollRange. For an in-depth understanding of UIScrollView, it is recommended to view ObjC China’s article for understanding Scroll Views, which will not be detailed here. By default, everyone can understand the relevant concepts of UIScrollView.

Then began to explain SPStackedNavigationScrollView concrete realization. See the following figure, only when the screen rootView when no split screen View SPStackedNavigationScrollView frame at the upper left of the origin of coordinates is the rootView, This time the contentOffset SPStackedNavigationScrollView = 0.




contentOffset = 0

So if we look at the picture, when we have a split View on the screen, we’re going to call it Container1. SPStackedNavigationScrollView frame of the coordinates of the origin is the upper left corner in Container1, This time SPStackedNavigationScrollView contentOffset = rootView. Width / 2.




contentOffset = rootView.width / 2

So if we look at the diagram, when we have two split views on the screen, we’re going to call them Container1 and Container2. SPStackedNavigationScrollView frame of the coordinates of the origin is the upper left corner in Container1, This time SPStackedNavigationScrollView contentOffset = rootView. Width.




contentOffset = rootView.width

It is not hard to see from the diagram above understanding SPStackedNavigationScrollView is focused on understanding SPStackedNavigationScrollView changing frame origin and contentOffset. As long as contentOffset changed, then SPStackedNavigationScrollView scrolling occurs.

See SPStackedNavigationScrollView. M file, see the two variables related to contentOffset _actualOffset and _targetOffset, next to monitor the changes of these two variables.

@implementation SPStackedNavigationScrollView { CGPoint _actualOffset; // Simulate ScrollView's current contentOffset CGPoint _targetOffset; // Simulate the contentOffset to which ScrollView will be rolledCopy the code

Check SPStackedNavigationScrollView – (void) setContentOffset: CGPoint contentOffset animated: (BOOL) animated method, role assignment _targetOffset and _actualOffset.

// copy UIScrollView to scroll to the specified location - (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated {// give _targetOffset Assign _targetOffset = contentOffset; if (animated) [self animateToTargetScrollOffset]; Else {// Assign _actualOffset to _actualOffset = _targetOffset; if (_onScrollDone) { self.onScrollDone(); self.onScrollDone = nil; } // Key step [self setNeedsLayout]; }}Copy the code

UIView calls the layoutSubviews method after calling the setNeedsLayout method. Take a look at this method.

- (void)layoutSubviews {// Use pen to stretch scroll at start and end // Use pen to stretch scroll at start and end // Use pen to stretch scroll at start and end // Use pen to stretch scroll at start and end // Use pen to stretch scroll at start and end // Use pen to stretch scroll at start and end.  // When _actualOffset is changed, the pen frame is calculated according to specific rules, and then the frame is assigned to the View. // Pen is the frame for each split screen. Pen = CGRectZero; // Why is -_actualoffset.x needed? / / in order to get the coordinates of each split-screen View of the value of X (the origin is the origin of coordinates SPStackedNavigationScrollView, // See pen.origin. X = -_actualoffset.x; // Stretch scroll at start and end if (_actualOffset. X < 0){ // Pen.origin. X = -_actualoffset. X /2; } CGFloat maxScroll = [self scrollOffsetForAligningPageWithRightEdge:self.subviews.lastObject]; if (_actualOffset.x > maxScroll){ pen.origin.x = -(maxScroll + (_actualOffset.x-maxScroll)/2); } int i = 0; / / markedForSuperviewRemovalOffset tag pageC own offset coordinate / / is used to the superview pageC moved to from the current position MarkedForSuperviewRemovalOffset specified coordinates / / can make their own View / / to the position of the edge of the cascade effect to make corresponding can also let the pageC their full screen or screen, CGFloat markedForSuperviewRemovalOffset = pen.origin.x; NSMutableArray *stackedViews = [NSMutableArray array]; for(SPStackedPageContainer *pageC in self.subviews) { pen.size = pageC.bounds.size; pen.size.height = self.frame.size.height; if (pageC.vc.stackedNavigationPageSize == kStackedPageFullSize) pen.size.width = self.frame.size.width; CGRect actualPen = pen; if (pageC.markedForSuperviewRemoval) actualPen.origin.x = markedForSuperviewRemovalOffset; // Stack on the left < (0,1,2,3) *3 // If (actualPen.origin. X < (MIN(I, 3))*3){// If (actualPen.origin. 3))*3 Then the position of the pageC is not within the top three of stackedViews. }else{ pageC.hidden = NO; } the if (self scrollAnimationTimer = = nil) / / floorf actualPen integer operation. The origin, x = floorf actualPen. Origin. (x); Pagec. frame = actualPen; // Change pagec. frame to actualPen; markedForSuperviewRemovalOffset += pen.size.width; / / NavVC do POP operation will markedForSuperviewRemoval set to YES / / in front of the pen. The origin, x = - _actualOffset. X; Width if (!) {pen.size. Width if (! pageC.markedForSuperviewRemoval) pen.origin.x += pen.size.width; If (actualPen.origin. X <= 0 &&pagec! = [self. function ()); // animation-function pagec.overlayopacity = 0.3/ actualpen.size. Width *abs(actualpen.origin. } else {pagec. overlayOpacity = 0.0; } i++; } i = 0; for (NSInteger index = 0; index < [stackedViews count]; index++) { SPStackedPageContainer *pageC = stackedViews[index]; // stackedViews include RootVC View; If ([stackedViews count] > 3 && index < ([stackedViews count]-3)) pagec. hidden = YES; Else {// Left is a stackedViews, with up to 3 layers of edge layering pagec. hidden = NO; CGRect frame = pageC.frame; Frame.origination. X = 0 + MIN(I, 3)*3; pageC.frame = frame; i++; } } // Only make sure we show what we need to, don't unload stuff until we're done animating [self updateContainerVisibilityByShowing:YES byHiding:NO]; }Copy the code

In the layoutSubviews method, calculate the frame of each split screen according to _actualOffset, which split screen can be displayed on the screen, which split screen needs to be removed, and which split screen position is to the left of the split screen displayed. Which split screen positions are to the right of the split screen displayed on the screen.

The layoutSubviews method calls a method that controls the display and hiding of a split View, where the concept of a split View can be equated to SPStackedPageContainer. This method is the – (void) updateContainerVisibilityByShowing: (BOOL) doShow byHiding doHide: (BOOL).

- (void) updateContainerVisibilityByShowing: (BOOL) doShow byHiding doHide: (BOOL) {/ / / / of the absolute value of fabsf floating-point split-screen View whether you need to bounce effect BOOL bouncing = self.scrollAnimationTimer && fabsf(_targetOffset.x - _actualOffset.x) < 30; // The pen of layoutSubViews is a frame, // The pen of layoutSubViews is a frame x coordinate // But the usage is the same as the pen of layoutSubViews -_actualOffset.x; // stretch scroll at start and end if (_actualOffset.x < 0) pen = -_actualOffset.x/2; CGFloat maxScroll = [self scrollOffsetForAligningPageWithRightEdge:self.subviews.lastObject]; if (_actualOffset.x > maxScroll) pen = -(maxScroll + (_actualOffset.x-maxScroll)/2); / / used to make mobile pageC SuperView coordinates x, the origin is the most on the left side of the screen shows the x coordinate split screen CGFloat markedForSuperviewRemovalOffset = pen; NSMutableArray *viewsToDelete = [NSMutableArray array]; for(SPStackedPageContainer *pageC in self.subviews) { CGFloat currentPen = pen; / / the pageC was made POP operation, need to be SuperView removed the if (pageC. MarkedForSuperviewRemoval) currentPen = markedForSuperviewRemovalOffset;  BOOL isOffScreenToTheRight = currentPen >= self.bounds.size.width; NSRange scrollRange = [self scrollRangeForPageContainer:pageC]; BOOL isCovered = currentPen + ScrollRange.length <= 0; BOOL isVisible =! isOffScreenToTheRight && ! isCovered; / / pageC visibility change && ((isVisible = = NO && doHide = = Yes) | | isVisible = = Yes && doShow = = Yes) / / as long as the pageC change visibility, The following if conditional branch if (pagec.vcvisible! = isVisible && ((! IsVisible && doHide) | | (isVisible && doShow))) {/ / pageC split screen will appear separately will leave the screen / / / / pageC (isVisible = = No | | bouncing = = No | | (isVisible ==Yes && needsInitialPresentation == Yes)) if (! isVisible || ! bouncing || (isVisible && pageC.needsInitialPresentation)) { pageC.needsInitialPresentation = NO; pageC.VCVisible = isVisible; }} / / to hide the pageC pageC and is marked for destruction / / (doHide = = Yes && pageC. MarkedForSuperviewRemoval = = Yes) / / add the pageC destruction of the array viewsToDelete if (doHide && pageC.markedForSuperviewRemoval) [viewsToDelete addObject:pageC]; / / after the Demo verification pen like markedForSuperviewRemovalOffset value markedForSuperviewRemovalOffset + = pageC. Frame. The size. The width; / / markedForSuperviewRemoval = No/pen/calculation values, the value is a split screen under the X coordinate of the if (! pageC.markedForSuperviewRemoval) pen += pageC.frame.size.width; } / / viewsToDelete array inside View of the execution of destruction operation [viewsToDelete makeObjectsPerformSelector: @ the selector (removeFromSuperview)]; }Copy the code

Limited to space relation can’t introduced SPStackedNavigationScrollView various implementations. Not to introduce knowledge including but not limited to the details of the NSRunLoop, to track SPStackedNavigationScrollView touch sliding, guarantee the interface slip is not affected by other Mode. SPStackedNavigationScrollView scrollRange calculation details, SPStackedNavigationScrollView gestures processing, etc., If you are interested, you can download the corresponding annotated version of the source code on my GitHub at github.com/junbinchenc… .

conclusion

The SPStackedNav project is a UI solution for iPad split screens. The core of the scheme is SPStackedNavigationScrollView this class. The realization of SPStackedNavigationScrollView mimics the UIScrollView. SPStackedNav’s split-screen solution is a neat one, with many details to learn from and some of the contentOffset calculations are clever. My ability is limited, the article inevitably has deficiencies, if you find, please point out in the comments, after confirmation, immediately modify, thank you!

reference

Scroll Views www.objccn.io/issue-3-2/ SPStackedNav github.com/spotify/SPS… SPStackedNav – Note github.com/junbinchenc…