// // FLEXExplorerViewController.m // Flipboard // // Created by Ryan Olson on 4/4/14. // Copyright (c) 2020 FLEX Team. All rights reserved. // #import "FLEXExplorerViewController.h" #import "FLEXExplorerToolbarItem.h" #import "FLEXUtility.h" #import "FLEXWindow.h" #import "FLEXTabList.h" #import "FLEXNavigationController.h" #import "FLEXHierarchyViewController.h" #import "FLEXGlobalsViewController.h" #import "FLEXObjectExplorerViewController.h" #import "FLEXObjectExplorerFactory.h" #import "FLEXNetworkMITMViewController.h" #import "FLEXTabsViewController.h" #import "FLEXWindowManagerController.h" #import "FLEXViewControllersViewController.h" #import "NSUserDefaults+FLEX.h" #import "FLEXManager.h" #import "FLEXResources.h" typedef NS_ENUM(NSUInteger, FLEXExplorerMode) { FLEXExplorerModeDefault, FLEXExplorerModeSelect, FLEXExplorerModeMove }; @interface FLEXExplorerViewController () { UIImageView *cursorView; } /// Tracks the currently active tool/mode @property (nonatomic) FLEXExplorerMode currentMode; /// Gesture recognizer for dragging a view in move mode @property (nonatomic) UIPanGestureRecognizer *movePanGR; /// Gesture recognizer for showing additional details on the selected view @property (nonatomic) UITapGestureRecognizer *detailsTapGR; /// Only valid while a move pan gesture is in progress. @property (nonatomic) CGRect selectedViewFrameBeforeDragging; /// Only valid while a toolbar drag pan gesture is in progress. @property (nonatomic) CGRect toolbarFrameBeforeDragging; /// Only valid while a selected view pan gesture is in progress. @property (nonatomic) CGFloat selectedViewLastPanX; /// Borders of all the visible views in the hierarchy at the selection point. /// The keys are NSValues with the corresponding view (nonretained). @property (nonatomic) NSDictionary *outlineViewsForVisibleViews; /// The actual views at the selection point with the deepest view last. @property (nonatomic) NSArray *viewsAtTapPoint; /// The view that we're currently highlighting with an overlay and displaying details for. @property (nonatomic) UIView *selectedView; /// A colored transparent overlay to indicate that the view is selected. @property (nonatomic) UIView *selectedViewOverlay; /// self.view.window as a \c FLEXWindow @property (nonatomic, readonly) FLEXWindow *window; /// All views that we're KVOing. Used to help us clean up properly. @property (nonatomic) NSMutableSet *observedViews; #if !TARGET_OS_TV /// Used to actuate changes in view selection on iOS 10+ @property (nonatomic, readonly) UISelectionFeedbackGenerator *selectionFBG API_AVAILABLE(ios(10.0)); /// Used to preserve the target app's UIMenuController items. @property (nonatomic) NSArray *appMenuItems; #endif @property CGPoint lastTouchLocation; @end @implementation FLEXExplorerViewController #pragma mark - Cursor Input #if TARGET_OS_TV - (void)pressesBegan:(NSSet *)presses withEvent:(UIPressesEvent *)event { for (UIPress *press in presses) { if (press.type == UIPressTypeMenu) { } else { [super pressesBegan:presses withEvent:event]; } } } -(void)pressesEnded:(NSSet *)presses withEvent:(UIPressesEvent *)event { FXLog(@"presses.anyObject.type: %lu", presses.anyObject.type); if (self.currentMode != FLEXExplorerModeSelect && self.currentMode != FLEXExplorerModeMove){ [super pressesEnded:presses withEvent:event]; return; } CGPoint point = [self.view convertPoint:cursorView.frame.origin toView:nil]; FXLog(@"clicked point: %@", NSStringFromCGPoint(point)); if (self.currentMode == FLEXExplorerModeSelect){ [self updateOutlineViewsForSelectionPoint:point]; } if (presses.anyObject.type == UIPressTypeMenu) { if (self.currentMode == FLEXExplorerModeMove){ self.currentMode = FLEXExplorerModeSelect; cursorView.hidden = false; } else if (self.currentMode == FLEXExplorerModeSelect){ self.currentMode = FLEXExplorerModeDefault; cursorView.hidden = true; [self enableToolbar]; } } else if (presses.anyObject.type == UIPressTypeUpArrow) { } else if (presses.anyObject.type == UIPressTypeDownArrow) { } else if (presses.anyObject.type == UIPressTypeSelect) { } else if (presses.anyObject.type == UIPressTypePlayPause){ } } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { self.lastTouchLocation = CGPointMake(-1, -1); } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { for (UITouch *touch in touches) { CGPoint location = [touch locationInView:self.view]; if(self.lastTouchLocation.x == -1 && self.lastTouchLocation.y == -1) { // Prevent cursor from recentering self.lastTouchLocation = location; } else { CGFloat xDiff = location.x - self.lastTouchLocation.x; CGFloat yDiff = location.y - self.lastTouchLocation.y; CGRect rect = cursorView.frame; if(rect.origin.x + xDiff >= 0 && rect.origin.x + xDiff <= 1920) rect.origin.x += xDiff;//location.x - self.startPos.x;//+= xDiff; //location.x; if(rect.origin.y + yDiff >= 0 && rect.origin.y + yDiff <= 1080) rect.origin.y += yDiff;//location.y - self.startPos.y;//+= yDiff; //location.y; cursorView.frame = rect; self.lastTouchLocation = location; } // We only use one touch, break the loop break; } } #endif - (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil { self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; if (self) { self.observedViews = [NSMutableSet new]; } return self; } - (void)dealloc { for (UIView *view in _observedViews) { [self stopObservingView:view]; } } - (void)viewDidLoad { [super viewDidLoad]; // Toolbar _explorerToolbar = [FLEXExplorerToolbar new]; // Start the toolbar off below any bars that may be at the top of the view. CGFloat toolbarOriginY = NSUserDefaults.standardUserDefaults.flex_toolbarTopMargin; CGRect safeArea = [self viewSafeArea]; CGSize toolbarSize = [self.explorerToolbar sizeThatFits:CGSizeMake( CGRectGetWidth(self.view.bounds), CGRectGetHeight(safeArea) )]; [self updateToolbarPositionWithUnconstrainedFrame:CGRectMake( CGRectGetMinX(safeArea), toolbarOriginY, toolbarSize.width, toolbarSize.height )]; self.explorerToolbar.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleBottomMargin | UIViewAutoresizingFlexibleTopMargin; [self.view addSubview:self.explorerToolbar]; [self setupToolbarActions]; [self setupToolbarGestures]; // View selection UITapGestureRecognizer *selectionTapGR = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleSelectionTap:) ]; [self.view addGestureRecognizer:selectionTapGR]; // View moving self.movePanGR = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handleMovePan:)]; self.movePanGR.enabled = self.currentMode == FLEXExplorerModeMove; [self.view addGestureRecognizer:self.movePanGR]; #if !TARGET_OS_TV // Feedback if (@available(iOS 10.0, *)) { _selectionFBG = [UISelectionFeedbackGenerator new]; } #else cursorView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 64, 64)]; cursorView.center = CGPointMake(CGRectGetMidX([UIScreen mainScreen].bounds), CGRectGetMidY([UIScreen mainScreen].bounds)); cursorView.image = [FLEXResources cursorImage]; cursorView.backgroundColor = [UIColor clearColor]; cursorView.hidden = YES; UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(doubleTap:)]; longPress.allowedPressTypes = @[[NSNumber numberWithInteger:UIPressTypePlayPause], [NSNumber numberWithInteger:UIPressTypeSelect]]; [self.view addGestureRecognizer:longPress]; [self.view addSubview:cursorView]; UITapGestureRecognizer *doubleTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(doubleTap:)]; doubleTap.allowedPressTypes = @[[NSNumber numberWithInteger:UIPressTypePlayPause], [NSNumber numberWithInteger:UIPressTypeSelect]]; doubleTap.numberOfTapsRequired = 2; [self.view addGestureRecognizer:doubleTap]; UITapGestureRecognizer *rightTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(doubleTap:)]; rightTap.allowedPressTypes = @[[NSNumber numberWithInteger:UIPressTypeRightArrow]]; [self.view addGestureRecognizer:rightTap]; #endif } - (void)doubleTap:(UITapGestureRecognizer *)gesture { if (gesture.state == UIGestureRecognizerStateEnded) { if (self.currentMode == FLEXExplorerModeSelect || self.currentMode == FLEXExplorerModeMove){ [self showTVOSOptionsAlert]; } } } - (void)showObjectControllerForSelectedView { FLEXObjectExplorerViewController *viewExplorer = [FLEXObjectExplorerFactory explorerViewControllerForObject:self.selectedView]; if (!viewExplorer) return; if ([self presentedViewController]){ FLEXHierarchyViewController *vc = (FLEXHierarchyViewController*)[self presentedViewController]; if ([vc respondsToSelector:@selector(pushViewController:animated:)]){ [vc pushViewController:viewExplorer animated:true]; } } else { [self toggleViewsToolWithCompletion:^{ FLEXHierarchyViewController *vc = (FLEXHierarchyViewController*)[self presentedViewController]; if ([vc respondsToSelector:@selector(pushViewController:animated:)]){ [vc pushViewController:viewExplorer animated:true]; } }]; } } - (void)showTVOSOptionsAlert { [FLEXAlert makeAlert:^(FLEXAlert *make) { make.title(@"What would you like to do?"); make.button(@"Show Details").handler(^(NSArray *strings) { [self showObjectControllerForSelectedView]; [[FLEXManager sharedManager] showExplorer]; }); if (self.currentMode == FLEXExplorerModeMove){ make.button(@"Select View").handler(^(NSArray *strings) { self.currentMode = FLEXExplorerModeSelect; cursorView.hidden = false; [[FLEXManager sharedManager] showExplorer]; }); } else if (self.currentMode == FLEXExplorerModeSelect){ make.button(@"Move View").handler(^(NSArray *strings) { self.currentMode = FLEXExplorerModeMove; cursorView.hidden = true; [[FLEXManager sharedManager] showExplorer]; }); } make.button(@"Show Views").handler(^(NSArray *strings) { [self toggleViewsTool]; [[FLEXManager sharedManager] showExplorer]; }); make.button(@"Show Usage Hints").handler(^(NSArray *strings) { [[FLEXManager sharedManager] showHintsAlert]; }); make.button(@"Cancel").cancelStyle().handler(^(NSArray *strings) { [[FLEXManager sharedManager] showExplorer]; }); } showFrom:self]; } - (void)longPress:(UILongPressGestureRecognizer*)gesture { if ( gesture.state == UIGestureRecognizerStateEnded) { [self toggleSelectTool]; } } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; [self updateButtonStates]; } #pragma mark - Rotation - (UIViewController *)viewControllerForRotationAndOrientation { UIViewController *viewController = FLEXUtility.appKeyWindow.rootViewController; // Obfuscating selector _viewControllerForSupportedInterfaceOrientations NSString *viewControllerSelectorString = [@[ @"_vie", @"wContro", @"llerFor", @"Supported", @"Interface", @"Orientations" ] componentsJoinedByString:@""]; SEL viewControllerSelector = NSSelectorFromString(viewControllerSelectorString); if ([viewController respondsToSelector:viewControllerSelector]) { viewController = [viewController valueForKey:viewControllerSelectorString]; } return viewController; } - (UIInterfaceOrientationMask)supportedInterfaceOrientations { // Commenting this out until I can figure out a better way to solve this // if (self.window.isKeyWindow) { // [self.window resignKeyWindow]; // } UIViewController *viewControllerToAsk = [self viewControllerForRotationAndOrientation]; UIInterfaceOrientationMask supportedOrientations = FLEXUtility.infoPlistSupportedInterfaceOrientationsMask; if (viewControllerToAsk && ![viewControllerToAsk isKindOfClass:[self class]]) { supportedOrientations = [viewControllerToAsk supportedInterfaceOrientations]; } // The UIViewController docs state that this method must not return zero. // If we weren't able to get a valid value for the supported interface // orientations, default to all supported. if (supportedOrientations == 0) { supportedOrientations = UIInterfaceOrientationMaskAll; } return supportedOrientations; } - (BOOL)shouldAutorotate { UIViewController *viewControllerToAsk = [self viewControllerForRotationAndOrientation]; BOOL shouldAutorotate = YES; if (viewControllerToAsk && viewControllerToAsk != self) { shouldAutorotate = [viewControllerToAsk shouldAutorotate]; } return shouldAutorotate; } - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator { [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; [coordinator animateAlongsideTransition:^(id context) { for (UIView *outlineView in self.outlineViewsForVisibleViews.allValues) { outlineView.hidden = YES; } self.selectedViewOverlay.hidden = YES; } completion:^(id context) { for (UIView *view in self.viewsAtTapPoint) { NSValue *key = [NSValue valueWithNonretainedObject:view]; UIView *outlineView = self.outlineViewsForVisibleViews[key]; outlineView.frame = [self frameInLocalCoordinatesForView:view]; if (self.currentMode == FLEXExplorerModeSelect) { outlineView.hidden = NO; } } if (self.selectedView) { self.selectedViewOverlay.frame = [self frameInLocalCoordinatesForView:self.selectedView]; self.selectedViewOverlay.hidden = NO; } }]; } #pragma mark - Setter Overrides - (void)setSelectedView:(UIView *)selectedView { if (![_selectedView isEqual:selectedView]) { if (![self.viewsAtTapPoint containsObject:_selectedView]) { [self stopObservingView:_selectedView]; } _selectedView = selectedView; [self beginObservingView:selectedView]; // Update the toolbar and selected overlay self.explorerToolbar.selectedViewDescription = [FLEXUtility descriptionForView:selectedView includingFrame:YES ]; self.explorerToolbar.selectedViewOverlayColor = [FLEXUtility consistentRandomColorForObject:selectedView ]; if (selectedView) { if (!self.selectedViewOverlay) { self.selectedViewOverlay = [UIView new]; [self.view addSubview:self.selectedViewOverlay]; self.selectedViewOverlay.layer.borderWidth = 1.0; } UIColor *outlineColor = [FLEXUtility consistentRandomColorForObject:selectedView]; self.selectedViewOverlay.backgroundColor = [outlineColor colorWithAlphaComponent:0.2]; self.selectedViewOverlay.layer.borderColor = outlineColor.CGColor; self.selectedViewOverlay.frame = [self.view convertRect:selectedView.bounds fromView:selectedView]; // Make sure the selected overlay is in front of all the other subviews // except the toolbar, which should always stay on top. [self.view bringSubviewToFront:self.selectedViewOverlay]; [self.view bringSubviewToFront:self.explorerToolbar]; } else { [self.selectedViewOverlay removeFromSuperview]; self.selectedViewOverlay = nil; } // Some of the button states depend on whether we have a selected view. [self updateButtonStates]; } } - (void)setViewsAtTapPoint:(NSArray *)viewsAtTapPoint { if (![_viewsAtTapPoint isEqual:viewsAtTapPoint]) { for (UIView *view in _viewsAtTapPoint) { if (view != self.selectedView) { [self stopObservingView:view]; } } _viewsAtTapPoint = viewsAtTapPoint; for (UIView *view in viewsAtTapPoint) { [self beginObservingView:view]; } } } - (void)setCurrentMode:(FLEXExplorerMode)currentMode { if (_currentMode != currentMode) { _currentMode = currentMode; switch (currentMode) { case FLEXExplorerModeDefault: [self removeAndClearOutlineViews]; self.viewsAtTapPoint = nil; self.selectedView = nil; break; case FLEXExplorerModeSelect: // Make sure the outline views are unhidden in case we came from the move mode. for (NSValue *key in self.outlineViewsForVisibleViews) { UIView *outlineView = self.outlineViewsForVisibleViews[key]; outlineView.hidden = NO; } break; case FLEXExplorerModeMove: // Hide all the outline views to focus on the selected view, // which is the only one that will move. for (NSValue *key in self.outlineViewsForVisibleViews) { UIView *outlineView = self.outlineViewsForVisibleViews[key]; outlineView.hidden = YES; } break; } self.movePanGR.enabled = currentMode == FLEXExplorerModeMove; [self updateButtonStates]; } } #pragma mark - View Tracking - (void)beginObservingView:(UIView *)view { // Bail if we're already observing this view or if there's nothing to observe. if (!view || [self.observedViews containsObject:view]) { return; } for (NSString *keyPath in self.viewKeyPathsToTrack) { [view addObserver:self forKeyPath:keyPath options:0 context:NULL]; } [self.observedViews addObject:view]; } - (void)stopObservingView:(UIView *)view { if (!view) { return; } for (NSString *keyPath in self.viewKeyPathsToTrack) { [view removeObserver:self forKeyPath:keyPath]; } [self.observedViews removeObject:view]; } - (NSArray *)viewKeyPathsToTrack { static NSArray *trackedViewKeyPaths = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ NSString *frameKeyPath = NSStringFromSelector(@selector(frame)); trackedViewKeyPaths = @[frameKeyPath]; }); return trackedViewKeyPaths; } - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { [self updateOverlayAndDescriptionForObjectIfNeeded:object]; } - (void)updateOverlayAndDescriptionForObjectIfNeeded:(id)object { NSUInteger indexOfView = [self.viewsAtTapPoint indexOfObject:object]; if (indexOfView != NSNotFound) { UIView *view = self.viewsAtTapPoint[indexOfView]; NSValue *key = [NSValue valueWithNonretainedObject:view]; UIView *outline = self.outlineViewsForVisibleViews[key]; if (outline) { outline.frame = [self frameInLocalCoordinatesForView:view]; } } if (object == self.selectedView) { // Update the selected view description since we show the frame value there. self.explorerToolbar.selectedViewDescription = [FLEXUtility descriptionForView:self.selectedView includingFrame:YES ]; CGRect selectedViewOutlineFrame = [self frameInLocalCoordinatesForView:self.selectedView]; self.selectedViewOverlay.frame = selectedViewOutlineFrame; } } - (CGRect)frameInLocalCoordinatesForView:(UIView *)view { // Convert to window coordinates since the view may be in a different window than our view CGRect frameInWindow = [view convertRect:view.bounds toView:nil]; // Convert from the window to our view's coordinate space return [self.view convertRect:frameInWindow fromView:nil]; } #pragma mark - Toolbar Buttons - (void)setupToolbarActions { FLEXExplorerToolbar *toolbar = self.explorerToolbar; NSDictionary *actionsToItems = @{ NSStringFromSelector(@selector(selectButtonTapped:)): toolbar.selectItem, NSStringFromSelector(@selector(hierarchyButtonTapped:)): toolbar.hierarchyItem, NSStringFromSelector(@selector(recentButtonTapped:)): toolbar.recentItem, NSStringFromSelector(@selector(moveButtonTapped:)): toolbar.moveItem, NSStringFromSelector(@selector(globalsButtonTapped:)): toolbar.globalsItem, NSStringFromSelector(@selector(closeButtonTapped:)): toolbar.closeItem, }; [actionsToItems enumerateKeysAndObjectsUsingBlock:^(NSString *sel, FLEXExplorerToolbarItem *item, BOOL *stop) { #if !TARGET_OS_TV [item addTarget:self action:NSSelectorFromString(sel) forControlEvents:UIControlEventTouchUpInside]; #else [item addTarget:self action:NSSelectorFromString(sel) forControlEvents:UIControlEventPrimaryActionTriggered]; #endif }]; } - (void)selectButtonTapped:(FLEXExplorerToolbarItem *)sender { [self toggleSelectTool]; } - (void)hierarchyButtonTapped:(FLEXExplorerToolbarItem *)sender { [self toggleViewsTool]; } - (UIWindow *)statusWindow { NSString *statusBarString = [NSString stringWithFormat:@"%@arWindow", @"_statusB"]; return [UIApplication.sharedApplication valueForKey:statusBarString]; } - (void)recentButtonTapped:(FLEXExplorerToolbarItem *)sender { NSAssert(FLEXTabList.sharedList.activeTab, @"Must have active tab"); [self presentViewController:FLEXTabList.sharedList.activeTab animated:YES completion:nil]; } - (void)moveButtonTapped:(FLEXExplorerToolbarItem *)sender { [self toggleMoveTool]; } - (void)globalsButtonTapped:(FLEXExplorerToolbarItem *)sender { [self toggleMenuTool]; } - (void)closeButtonTapped:(FLEXExplorerToolbarItem *)sender { self.currentMode = FLEXExplorerModeDefault; [self.delegate explorerViewControllerDidFinish:self]; } - (void)updateButtonStates { FLEXExplorerToolbar *toolbar = self.explorerToolbar; toolbar.selectItem.selected = self.currentMode == FLEXExplorerModeSelect; // Move only enabled when an object is selected. BOOL hasSelectedObject = self.selectedView != nil; toolbar.moveItem.enabled = hasSelectedObject; toolbar.moveItem.selected = self.currentMode == FLEXExplorerModeMove; // Recent only enabled when we have a last active tab toolbar.recentItem.enabled = FLEXTabList.sharedList.activeTab != nil; } #pragma mark - Toolbar Dragging - (void)setupToolbarGestures { FLEXExplorerToolbar *toolbar = self.explorerToolbar; // Pan gesture for dragging. [toolbar.dragHandle addGestureRecognizer:[[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handleToolbarPanGesture:) ]]; // Tap gesture for hinting. [toolbar.dragHandle addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleToolbarHintTapGesture:) ]]; // Tap gesture for showing additional details self.detailsTapGR = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleToolbarDetailsTapGesture:) ]; [toolbar.selectedViewDescriptionContainer addGestureRecognizer:self.detailsTapGR]; // Swipe gestures for selecting deeper / higher views at a point UIPanGestureRecognizer *leftSwipe = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handleChangeViewAtPointGesture:) ]; // UIPanGestureRecognizer *rightSwipe = [[UIPanGestureRecognizer alloc] // initWithTarget:self action:@selector(handleChangeViewAtPointGesture:) // ]; // leftSwipe.direction = UISwipeGestureRecognizerDirectionLeft; // rightSwipe.direction = UISwipeGestureRecognizerDirectionRight; [toolbar.selectedViewDescriptionContainer addGestureRecognizer:leftSwipe]; // [toolbar.selectedViewDescriptionContainer addGestureRecognizer:rightSwipe]; // Long press gesture to present tabs manager [toolbar.globalsItem addGestureRecognizer:[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleToolbarShowTabsGesture:) ]]; // Long press gesture to present window manager [toolbar.selectItem addGestureRecognizer:[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleToolbarWindowManagerGesture:) ]]; // Long press gesture to present view controllers at tap [toolbar.hierarchyItem addGestureRecognizer:[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleToolbarShowViewControllersGesture:) ]]; } - (void)handleToolbarPanGesture:(UIPanGestureRecognizer *)panGR { switch (panGR.state) { case UIGestureRecognizerStateBegan: self.toolbarFrameBeforeDragging = self.explorerToolbar.frame; [self updateToolbarPositionWithDragGesture:panGR]; break; case UIGestureRecognizerStateChanged: case UIGestureRecognizerStateEnded: [self updateToolbarPositionWithDragGesture:panGR]; break; default: break; } } - (void)updateToolbarPositionWithDragGesture:(UIPanGestureRecognizer *)panGR { CGPoint translation = [panGR translationInView:self.view]; CGRect newToolbarFrame = self.toolbarFrameBeforeDragging; newToolbarFrame.origin.y += translation.y; [self updateToolbarPositionWithUnconstrainedFrame:newToolbarFrame]; } - (void)updateToolbarPositionWithUnconstrainedFrame:(CGRect)unconstrainedFrame { CGRect safeArea = [self viewSafeArea]; // We only constrain the Y-axis because we want the toolbar // to handle the X-axis safeArea layout by itself CGFloat minY = CGRectGetMinY(safeArea); CGFloat maxY = CGRectGetMaxY(safeArea) - unconstrainedFrame.size.height; if (unconstrainedFrame.origin.y < minY) { unconstrainedFrame.origin.y = minY; } else if (unconstrainedFrame.origin.y > maxY) { unconstrainedFrame.origin.y = maxY; } self.explorerToolbar.frame = unconstrainedFrame; NSUserDefaults.standardUserDefaults.flex_toolbarTopMargin = unconstrainedFrame.origin.y; } - (void)handleToolbarHintTapGesture:(UITapGestureRecognizer *)tapGR { // Bounce the toolbar to indicate that it is draggable. // TODO: make it bouncier. if (tapGR.state == UIGestureRecognizerStateRecognized) { CGRect originalToolbarFrame = self.explorerToolbar.frame; const NSTimeInterval kHalfwayDuration = 0.2; const CGFloat kVerticalOffset = 30.0; [UIView animateWithDuration:kHalfwayDuration delay:0 options:UIViewAnimationOptionCurveEaseOut animations:^{ CGRect newToolbarFrame = self.explorerToolbar.frame; newToolbarFrame.origin.y += kVerticalOffset; self.explorerToolbar.frame = newToolbarFrame; } completion:^(BOOL finished) { [UIView animateWithDuration:kHalfwayDuration delay:0 options:UIViewAnimationOptionCurveEaseIn animations:^{ self.explorerToolbar.frame = originalToolbarFrame; } completion:nil]; }]; } } - (void)handleToolbarDetailsTapGesture:(UITapGestureRecognizer *)tapGR { if (tapGR.state == UIGestureRecognizerStateRecognized && self.selectedView) { UIViewController *topStackVC = [FLEXObjectExplorerFactory explorerViewControllerForObject:self.selectedView]; [self presentViewController: [FLEXNavigationController withRootViewController:topStackVC] animated:YES completion:nil]; } } - (void)handleToolbarShowTabsGesture:(UILongPressGestureRecognizer *)sender { if (sender.state == UIGestureRecognizerStateBegan) { // Back up the UIMenuController items since dismissViewController: will attempt to replace them #if !TARGET_OS_TV self.appMenuItems = UIMenuController.sharedMenuController.menuItems; #endif // Don't use FLEXNavigationController because the tab viewer itself is not a tab [super presentViewController:[[UINavigationController alloc] initWithRootViewController:[FLEXTabsViewController new] ] animated:YES completion:nil]; } } - (void)handleToolbarWindowManagerGesture:(UILongPressGestureRecognizer *)sender { if (sender.state == UIGestureRecognizerStateBegan) { // Back up the UIMenuController items since dismissViewController: will attempt to replace them #if !TARGET_OS_TV self.appMenuItems = UIMenuController.sharedMenuController.menuItems; #endif [super presentViewController:[FLEXNavigationController withRootViewController:[FLEXWindowManagerController new] ] animated:YES completion:nil]; } } - (void)handleToolbarShowViewControllersGesture:(UILongPressGestureRecognizer *)sender { if (sender.state == UIGestureRecognizerStateBegan && self.viewsAtTapPoint.count) { // Back up the UIMenuController items since dismissViewController: will attempt to replace them #if !TARGET_OS_TV self.appMenuItems = UIMenuController.sharedMenuController.menuItems; #endif UIViewController *list = [FLEXViewControllersViewController controllersForViews:self.viewsAtTapPoint ]; [self presentViewController: [FLEXNavigationController withRootViewController:list ] animated:YES completion:nil]; } } #pragma mark - View Selection - (void)handleSelectionTap:(UITapGestureRecognizer *)tapGR { // Only if we're in selection mode if (self.currentMode == FLEXExplorerModeSelect && tapGR.state == UIGestureRecognizerStateRecognized) { // Note that [tapGR locationInView:nil] is broken in iOS 8, // so we have to do a two step conversion to window coordinates. // Thanks to @lascorbe for finding this: https://github.com/Flipboard/FLEX/pull/31 CGPoint tapPointInView = [tapGR locationInView:self.view]; CGPoint tapPointInWindow = [self.view convertPoint:tapPointInView toView:nil]; [self updateOutlineViewsForSelectionPoint:tapPointInWindow]; } } - (void)handleChangeViewAtPointGesture:(UIPanGestureRecognizer *)sender { NSInteger max = self.viewsAtTapPoint.count - 1; NSInteger currentIdx = [self.viewsAtTapPoint indexOfObject:self.selectedView]; CGFloat locationX = [sender locationInView:self.view].x; // Track the pan gesture: every N points we move along the X axis, // actuate some haptic feedback and move up or down the hierarchy. // We only store the "last" location when we've met the threshold. // We only change the view and actuate feedback if the view selection // changes; that is, as long as we don't go outside or under the array. switch (sender.state) { case UIGestureRecognizerStateBegan: { self.selectedViewLastPanX = locationX; break; } case UIGestureRecognizerStateChanged: { static CGFloat kNextLevelThreshold = 20.f; CGFloat lastX = self.selectedViewLastPanX; NSInteger newSelection = currentIdx; // Left, go down the hierarchy if (locationX < lastX && (lastX - locationX) >= kNextLevelThreshold) { // Choose a new view index up to the max index newSelection = MIN(max, currentIdx + 1); self.selectedViewLastPanX = locationX; } // Right, go up the hierarchy else if (lastX < locationX && (locationX - lastX) >= kNextLevelThreshold) { // Choose a new view index down to the min index newSelection = MAX(0, currentIdx - 1); self.selectedViewLastPanX = locationX; } if (currentIdx != newSelection) { self.selectedView = self.viewsAtTapPoint[newSelection]; [self actuateSelectionChangedFeedback]; } break; } default: break; } } - (void)actuateSelectionChangedFeedback { #if !TARGET_OS_TV if (@available(iOS 10.0, *)) { [self.selectionFBG selectionChanged]; } #endif } - (void)updateOutlineViewsForSelectionPoint:(CGPoint)selectionPointInWindow { [self removeAndClearOutlineViews]; // Include hidden views in the "viewsAtTapPoint" array so we can show them in the hierarchy list. self.viewsAtTapPoint = [self viewsAtPoint:selectionPointInWindow skipHiddenViews:NO]; // For outlined views and the selected view, only use visible views. // Outlining hidden views adds clutter and makes the selection behavior confusing. NSArray *visibleViewsAtTapPoint = [self viewsAtPoint:selectionPointInWindow skipHiddenViews:YES]; NSMutableDictionary *newOutlineViewsForVisibleViews = [NSMutableDictionary new]; for (UIView *view in visibleViewsAtTapPoint) { UIView *outlineView = [self outlineViewForView:view]; [self.view addSubview:outlineView]; NSValue *key = [NSValue valueWithNonretainedObject:view]; [newOutlineViewsForVisibleViews setObject:outlineView forKey:key]; } self.outlineViewsForVisibleViews = newOutlineViewsForVisibleViews; self.selectedView = [self viewForSelectionAtPoint:selectionPointInWindow]; // Make sure the explorer toolbar doesn't end up behind the newly added outline views. [self.view bringSubviewToFront:self.explorerToolbar]; [self updateButtonStates]; } - (UIView *)outlineViewForView:(UIView *)view { CGRect outlineFrame = [self frameInLocalCoordinatesForView:view]; UIView *outlineView = [[UIView alloc] initWithFrame:outlineFrame]; outlineView.backgroundColor = UIColor.clearColor; outlineView.layer.borderColor = [FLEXUtility consistentRandomColorForObject:view].CGColor; outlineView.layer.borderWidth = 1.0; return outlineView; } - (void)removeAndClearOutlineViews { for (NSValue *key in self.outlineViewsForVisibleViews) { UIView *outlineView = self.outlineViewsForVisibleViews[key]; [outlineView removeFromSuperview]; } self.outlineViewsForVisibleViews = nil; } - (NSArray *)viewsAtPoint:(CGPoint)tapPointInWindow skipHiddenViews:(BOOL)skipHidden { NSMutableArray *views = [NSMutableArray new]; for (UIWindow *window in FLEXUtility.allWindows) { // Don't include the explorer's own window or subviews. if (window != self.view.window && [window pointInside:tapPointInWindow withEvent:nil]) { [views addObject:window]; [views addObjectsFromArray:[self recursiveSubviewsAtPoint:tapPointInWindow inView:window skipHiddenViews:skipHidden ]]; } } return views; } - (UIView *)viewForSelectionAtPoint:(CGPoint)tapPointInWindow { // Select in the window that would handle the touch, but don't just use the result of // hitTest:withEvent: so we can still select views with interaction disabled. // Default to the the application's key window if none of the windows want the touch. UIWindow *windowForSelection = UIApplication.sharedApplication.keyWindow; for (UIWindow *window in FLEXUtility.allWindows.reverseObjectEnumerator) { // Ignore the explorer's own window. if (window != self.view.window) { if ([window hitTest:tapPointInWindow withEvent:nil]) { windowForSelection = window; break; } } } // Select the deepest visible view at the tap point. This generally corresponds to what the user wants to select. return [self recursiveSubviewsAtPoint:tapPointInWindow inView:windowForSelection skipHiddenViews:YES].lastObject; } - (NSArray *)recursiveSubviewsAtPoint:(CGPoint)pointInView inView:(UIView *)view skipHiddenViews:(BOOL)skipHidden { NSMutableArray *subviewsAtPoint = [NSMutableArray new]; for (UIView *subview in view.subviews) { BOOL isHidden = subview.hidden || subview.alpha < 0.01; if (skipHidden && isHidden) { continue; } BOOL subviewContainsPoint = CGRectContainsPoint(subview.frame, pointInView); if (subviewContainsPoint) { [subviewsAtPoint addObject:subview]; } // If this view doesn't clip to its bounds, we need to check its subviews even if it // doesn't contain the selection point. They may be visible and contain the selection point. if (subviewContainsPoint || !subview.clipsToBounds) { CGPoint pointInSubview = [view convertPoint:pointInView toView:subview]; [subviewsAtPoint addObjectsFromArray:[self recursiveSubviewsAtPoint:pointInSubview inView:subview skipHiddenViews:skipHidden ]]; } } return subviewsAtPoint; } #pragma mark - Selected View Moving - (void)handleMovePan:(UIPanGestureRecognizer *)movePanGR { switch (movePanGR.state) { case UIGestureRecognizerStateBegan: self.selectedViewFrameBeforeDragging = self.selectedView.frame; [self updateSelectedViewPositionWithDragGesture:movePanGR]; break; case UIGestureRecognizerStateChanged: case UIGestureRecognizerStateEnded: [self updateSelectedViewPositionWithDragGesture:movePanGR]; break; default: break; } } - (void)updateSelectedViewPositionWithDragGesture:(UIPanGestureRecognizer *)movePanGR { CGPoint translation = [movePanGR translationInView:self.selectedView.superview]; CGRect newSelectedViewFrame = self.selectedViewFrameBeforeDragging; newSelectedViewFrame.origin.x = FLEXFloor(newSelectedViewFrame.origin.x + translation.x); newSelectedViewFrame.origin.y = FLEXFloor(newSelectedViewFrame.origin.y + translation.y); self.selectedView.frame = newSelectedViewFrame; } #pragma mark - Safe Area Handling - (CGRect)viewSafeArea { CGRect safeArea = self.view.bounds; if (@available(iOS 11.0, *)) { safeArea = UIEdgeInsetsInsetRect(self.view.bounds, self.view.safeAreaInsets); } return safeArea; } - (void)viewSafeAreaInsetsDidChange { if (@available(iOS 11.0, *)) { [super viewSafeAreaInsetsDidChange]; CGRect safeArea = [self viewSafeArea]; CGSize toolbarSize = [self.explorerToolbar sizeThatFits:CGSizeMake( CGRectGetWidth(self.view.bounds), CGRectGetHeight(safeArea) )]; [self updateToolbarPositionWithUnconstrainedFrame:CGRectMake( CGRectGetMinX(self.explorerToolbar.frame), CGRectGetMinY(self.explorerToolbar.frame), toolbarSize.width, toolbarSize.height) ]; } } #pragma mark - Touch Handling - (BOOL)shouldReceiveTouchAtWindowPoint:(CGPoint)pointInWindowCoordinates { BOOL shouldReceiveTouch = NO; CGPoint pointInLocalCoordinates = [self.view convertPoint:pointInWindowCoordinates fromView:nil]; // Always if it's on the toolbar if (CGRectContainsPoint(self.explorerToolbar.frame, pointInLocalCoordinates)) { shouldReceiveTouch = YES; } // Always if we're in selection mode if (!shouldReceiveTouch && self.currentMode == FLEXExplorerModeSelect) { shouldReceiveTouch = YES; } // Always in move mode too if (!shouldReceiveTouch && self.currentMode == FLEXExplorerModeMove) { shouldReceiveTouch = YES; } // Always if we have a modal presented if (!shouldReceiveTouch && self.presentedViewController) { shouldReceiveTouch = YES; } return shouldReceiveTouch; } #pragma mark - FLEXHierarchyDelegate - (void)viewHierarchyDidDismiss:(UIView *)selectedView { // Note that we need to wait until the view controller is dismissed to calculate the frame // of the outline view, otherwise the coordinate conversion doesn't give the correct result. [self toggleViewsToolWithCompletion:^{ // If the selected view is outside of the tap point array (selected from "Full Hierarchy"), // then clear out the tap point array and remove all the outline views. if (![self.viewsAtTapPoint containsObject:selectedView]) { self.viewsAtTapPoint = nil; [self removeAndClearOutlineViews]; } // If we now have a selected view and we didn't have one previously, go to "select" mode. if (self.currentMode == FLEXExplorerModeDefault && selectedView) { //self.currentMode = FLEXExplorerModeSelect; [self toggleSelectTool]; } // The selected view setter will also update the selected view overlay appropriately. self.selectedView = selectedView; }]; } #pragma mark - Modal Presentation and Window Management - (void)presentViewController:(UIViewController *)toPresent animated:(BOOL)animated completion:(void (^)(void))completion { // Make our window key to correctly handle input. [self.view.window makeKeyWindow]; // Move the status bar on top of FLEX so we can get scroll to top behavior for taps. if (!@available(iOS 13, *)) { [self statusWindow].windowLevel = self.view.window.windowLevel + 1.0; } #if !TARGET_OS_TV // Back up and replace the UIMenuController items // Edit: no longer replacing the items, but still backing them // up in case we start replacing them again in the future self.appMenuItems = UIMenuController.sharedMenuController.menuItems; #endif // Show the view controller [super presentViewController:toPresent animated:animated completion:completion]; } - (void)dismissViewControllerAnimated:(BOOL)animated completion:(void (^)(void))completion { UIWindow *appWindow = self.window.previousKeyWindow; [appWindow makeKeyWindow]; #if !TARGET_OS_TV [appWindow.rootViewController setNeedsStatusBarAppearanceUpdate]; // Restore previous UIMenuController items // Back up and replace the UIMenuController items UIMenuController.sharedMenuController.menuItems = self.appMenuItems; [UIMenuController.sharedMenuController update]; self.appMenuItems = nil; // Restore the status bar window's normal window level. // We want it above FLEX while a modal is presented for // scroll to top, but below FLEX otherwise for exploration. [self statusWindow].windowLevel = UIWindowLevelStatusBar; #endif [self updateButtonStates]; [super dismissViewControllerAnimated:animated completion:completion]; } - (BOOL)wantsWindowToBecomeKey { return self.window.previousKeyWindow != nil; } - (void)toggleToolWithViewControllerProvider:(UINavigationController *(^)(void))future completion:(void(^)(void))completion { if (self.presentedViewController) { [self dismissViewControllerAnimated:YES completion:completion]; } else if (future) { [self presentViewController:future() animated:YES completion:completion]; } } - (FLEXWindow *)window { return (id)self.view.window; } - (void)disableToolbar { [self.explorerToolbar setUserInteractionEnabled:false]; [self.explorerToolbar setAlpha:0.5]; [self setNeedsFocusUpdate]; [self updateFocusIfNeeded]; } - (void)enableToolbar { [self.explorerToolbar setUserInteractionEnabled:true]; [self.explorerToolbar setAlpha:1.0]; [self setNeedsFocusUpdate]; [self updateFocusIfNeeded]; } #pragma mark - Keyboard Shortcut Helpers - (void)toggleSelectTool { if (self.currentMode == FLEXExplorerModeSelect) { self.currentMode = FLEXExplorerModeDefault; cursorView.hidden = true; [self enableToolbar]; } else { self.currentMode = FLEXExplorerModeSelect; cursorView.hidden = false; [self disableToolbar]; } } - (void)toggleMoveTool { if (self.currentMode == FLEXExplorerModeMove) { self.currentMode = FLEXExplorerModeSelect; } else if (self.currentMode == FLEXExplorerModeSelect && self.selectedView) { self.currentMode = FLEXExplorerModeMove; } } - (void)toggleViewsTool { [self toggleViewsToolWithCompletion:nil]; } - (void)toggleViewsToolWithCompletion:(void(^)(void))completion { [self toggleToolWithViewControllerProvider:^UINavigationController *{ if (self.selectedView) { FXLog(@"we have a selected view still: %@", self.selectedView); return [FLEXHierarchyViewController delegate:self viewsAtTap:self.viewsAtTapPoint selectedView:self.selectedView ]; } else { return [FLEXHierarchyViewController delegate:self]; } } completion:^{ if (completion) { completion(); } }]; } - (void)toggleMenuTool { [self toggleToolWithViewControllerProvider:^UINavigationController *{ return [FLEXNavigationController withRootViewController:[FLEXGlobalsViewController new]]; } completion:nil]; } - (BOOL)handleDownArrowKeyPressed { if (self.currentMode == FLEXExplorerModeMove) { CGRect frame = self.selectedView.frame; frame.origin.y += 1.0 / UIScreen.mainScreen.scale; self.selectedView.frame = frame; } else if (self.currentMode == FLEXExplorerModeSelect && self.viewsAtTapPoint.count > 0) { NSInteger selectedViewIndex = [self.viewsAtTapPoint indexOfObject:self.selectedView]; if (selectedViewIndex > 0) { self.selectedView = [self.viewsAtTapPoint objectAtIndex:selectedViewIndex - 1]; } } else { return NO; } return YES; } - (BOOL)handleUpArrowKeyPressed { if (self.currentMode == FLEXExplorerModeMove) { CGRect frame = self.selectedView.frame; frame.origin.y -= 1.0 / UIScreen.mainScreen.scale; self.selectedView.frame = frame; } else if (self.currentMode == FLEXExplorerModeSelect && self.viewsAtTapPoint.count > 0) { NSInteger selectedViewIndex = [self.viewsAtTapPoint indexOfObject:self.selectedView]; if (selectedViewIndex < self.viewsAtTapPoint.count - 1) { self.selectedView = [self.viewsAtTapPoint objectAtIndex:selectedViewIndex + 1]; } } else { return NO; } return YES; } - (BOOL)handleRightArrowKeyPressed { if (self.currentMode == FLEXExplorerModeMove) { CGRect frame = self.selectedView.frame; frame.origin.x += 1.0 / UIScreen.mainScreen.scale; self.selectedView.frame = frame; return YES; } return NO; } - (BOOL)handleLeftArrowKeyPressed { if (self.currentMode == FLEXExplorerModeMove) { CGRect frame = self.selectedView.frame; frame.origin.x -= 1.0 / UIScreen.mainScreen.scale; self.selectedView.frame = frame; return YES; } return NO; } @end