// // FLEXExplorerViewController.m // Flipboard // // Created by Ryan Olson on 4/4/14. // Copyright (c) 2014 Flipboard. All rights reserved. // #import "FLEXExplorerViewController.h" #import "FLEXExplorerToolbar.h" #import "FLEXToolbarItem.h" #import "FLEXUtility.h" #import "FLEXHierarchyTableViewController.h" #import "FLEXGlobalsTableViewController.h" #import "FLEXObjectExplorerViewController.h" #import "FLEXObjectExplorerFactory.h" #import "FLEXNetworkHistoryTableViewController.h" static NSString *const kFLEXToolbarTopMarginDefaultsKey = @"com.flex.FLEXToolbar.topMargin"; typedef NS_ENUM(NSUInteger, FLEXExplorerMode) { FLEXExplorerModeDefault, FLEXExplorerModeSelect, FLEXExplorerModeMove }; @interface FLEXExplorerViewController () @property (nonatomic) FLEXExplorerToolbar *explorerToolbar; /// 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; /// 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; /// Tracked so we can restore the key window after dismissing a modal. /// We need to become key after modal presentation so we can correctly capture input. /// If we're just showing the toolbar, we want the main app's window to remain key so that we don't interfere with input, status bar, etc. @property (nonatomic) UIWindow *previousKeyWindow; /// All views that we're KVOing. Used to help us clean up properly. @property (nonatomic) NSMutableSet *observedViews; @end @implementation FLEXExplorerViewController - (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil { self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; if (self) { self.observedViews = [NSMutableSet set]; } return self; } -(void)dealloc { for (UIView *view in _observedViews) { [self stopObservingView:view]; } } - (void)viewDidLoad { [super viewDidLoad]; // Toolbar self.explorerToolbar = [FLEXExplorerToolbar new]; // Start the toolbar off below any bars that may be at the top of the view. id toolbarOriginYDefault = [[NSUserDefaults standardUserDefaults] objectForKey:kFLEXToolbarTopMarginDefaultsKey]; CGFloat toolbarOriginY = toolbarOriginYDefault ? [toolbarOriginYDefault doubleValue] : 100; 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]; } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; [self updateButtonStates]; } #pragma mark - Rotation - (UIViewController *)viewControllerForRotationAndOrientation { UIWindow *window = self.previousKeyWindow ?: [UIApplication.sharedApplication keyWindow]; UIViewController *viewController = window.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 { UIViewController *viewControllerToAsk = [self viewControllerForRotationAndOrientation]; UIInterfaceOrientationMask supportedOrientations = [FLEXUtility infoPlistSupportedInterfaceOrientationsMask]; if (viewControllerToAsk && viewControllerToAsk != self) { 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 { [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 class] 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 class] 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 { // First convert to window coordinates since the view may be in a different window than our view. CGRect frameInWindow = [view convertRect:view.bounds toView:nil]; // Then convert from the window to our view's coordinate space. return [self.view convertRect:frameInWindow fromView:nil]; } #pragma mark - Toolbar Buttons - (void)setupToolbarActions { [self.explorerToolbar.selectItem addTarget:self action:@selector(selectButtonTapped:) forControlEvents:UIControlEventTouchUpInside]; [self.explorerToolbar.hierarchyItem addTarget:self action:@selector(hierarchyButtonTapped:) forControlEvents:UIControlEventTouchUpInside]; [self.explorerToolbar.moveItem addTarget:self action:@selector(moveButtonTapped:) forControlEvents:UIControlEventTouchUpInside]; [self.explorerToolbar.globalsItem addTarget:self action:@selector(globalsButtonTapped:) forControlEvents:UIControlEventTouchUpInside]; [self.explorerToolbar.closeItem addTarget:self action:@selector(closeButtonTapped:) forControlEvents:UIControlEventTouchUpInside]; } - (void)selectButtonTapped:(FLEXToolbarItem *)sender { [self toggleSelectTool]; } - (void)hierarchyButtonTapped:(FLEXToolbarItem *)sender { [self toggleViewsTool]; } - (NSArray *)allViewsInHierarchy { NSMutableArray *allViews = [NSMutableArray array]; NSArray *windows = [FLEXUtility allWindows]; for (UIWindow *window in windows) { if (window != self.view.window) { [allViews addObject:window]; [allViews addObjectsFromArray:[self allRecursiveSubviewsInView:window]]; } } return allViews; } - (UIWindow *)statusWindow { NSString *statusBarString = [NSString stringWithFormat:@"%@arWindow", @"_statusB"]; return [UIApplication.sharedApplication valueForKey:statusBarString]; } - (void)moveButtonTapped:(FLEXToolbarItem *)sender { [self toggleMoveTool]; } - (void)globalsButtonTapped:(FLEXToolbarItem *)sender { [self toggleMenuTool]; } - (void)closeButtonTapped:(FLEXToolbarItem *)sender { self.currentMode = FLEXExplorerModeDefault; [self.delegate explorerViewControllerDidFinish:self]; } - (void)updateButtonStates { // Move and details only active when an object is selected. BOOL hasSelectedObject = self.selectedView != nil; self.explorerToolbar.moveItem.enabled = hasSelectedObject; self.explorerToolbar.selectItem.selected = self.currentMode == FLEXExplorerModeSelect; self.explorerToolbar.moveItem.selected = self.currentMode == FLEXExplorerModeMove; } #pragma mark - Toolbar Dragging - (void)setupToolbarGestures { // Pan gesture for dragging. UIPanGestureRecognizer *panGR = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handleToolbarPanGesture:)]; [self.explorerToolbar.dragHandle addGestureRecognizer:panGR]; // Tap gesture for hinting. UITapGestureRecognizer *hintTapGR = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleToolbarHintTapGesture:)]; [self.explorerToolbar.dragHandle addGestureRecognizer:hintTapGR]; // Tap gesture for showing additional details self.detailsTapGR = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleToolbarDetailsTapGesture:)]; [self.explorerToolbar.selectedViewDescriptionContainer addGestureRecognizer:self.detailsTapGR]; } - (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] setDouble:unconstrainedFrame.origin.y forKey:kFLEXToolbarTopMarginDefaultsKey]; } - (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) { FLEXObjectExplorerViewController *selectedViewExplorer = [FLEXObjectExplorerFactory explorerViewControllerForObject:self.selectedView]; selectedViewExplorer.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(selectedViewExplorerFinished:)]; UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:selectedViewExplorer]; [self makeKeyAndPresentViewController:navigationController 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)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 dictionary]; 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 array]; 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 array]; 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; } - (NSArray *)allRecursiveSubviewsInView:(UIView *)view { NSMutableArray *subviews = [NSMutableArray array]; for (UIView *subview in view.subviews) { [subviews addObject:subview]; [subviews addObjectsFromArray:[self allRecursiveSubviewsInView:subview]]; } return subviews; } - (NSDictionary *)hierarchyDepthsForViews:(NSArray *)views { NSMutableDictionary *hierarchyDepths = [NSMutableDictionary dictionary]; for (UIView *view in views) { NSInteger depth = 0; UIView *tryView = view; while (tryView.superview) { tryView = tryView.superview; depth++; } [hierarchyDepths setObject:@(depth) forKey:[NSValue valueWithNonretainedObject:view]]; } return hierarchyDepths; } #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 - FLEXHierarchyTableViewControllerDelegate - (void)hierarchyViewController:(FLEXHierarchyTableViewController *)hierarchyViewController didFinishWithSelectedView:(UIView *)selectedView { // Note that we need to wait until the view controller is dismissed to calculated 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; } // The selected view setter will also update the selected view overlay appropriately. self.selectedView = selectedView; }]; } #pragma mark - FLEXGlobalsViewControllerDelegate - (void)globalsViewControllerDidFinish:(FLEXGlobalsTableViewController *)globalsViewController { [self resignKeyAndDismissViewControllerAnimated:YES completion:nil]; } #pragma mark - FLEXObjectExplorerViewController Done Action - (void)selectedViewExplorerFinished:(id)sender { [self resignKeyAndDismissViewControllerAnimated:YES completion:nil]; } #pragma mark - Modal Presentation and Window Management - (void)makeKeyAndPresentViewController:(UIViewController *)viewController animated:(BOOL)animated completion:(void (^)(void))completion { // Save the current key window so we can restore it following dismissal. self.previousKeyWindow = UIApplication.sharedApplication.keyWindow; // Make our window key to correctly handle input. [self.view.window makeKeyWindow]; // Fix for iOS 13, regarding custom UIMenu callouts not appearing because // the UITextEffectsWindow has a lower level than the FLEX window by default // until a text field is activated, bringing it above the FLEX window. if (@available(iOS 13, *)) { for (UIWindow *window in UIApplication.sharedApplication.windows) { if ([window isKindOfClass:NSClassFromString(@"UITextEffectsWindow")]) { if (window.windowLevel <= self.view.window.windowLevel) { window.windowLevel = self.view.window.windowLevel + 1; break; } } } } // Move the status bar on top of FLEX so we can get scroll to top behavior for taps. [[self statusWindow] setWindowLevel:self.view.window.windowLevel + 1.0]; // Show the view controller. [self presentViewController:viewController animated:animated completion:completion]; } - (void)resignKeyAndDismissViewControllerAnimated:(BOOL)animated completion:(void (^)(void))completion { UIWindow *previousKeyWindow = self.previousKeyWindow; self.previousKeyWindow = nil; [previousKeyWindow makeKeyWindow]; [[previousKeyWindow rootViewController] setNeedsStatusBarAppearanceUpdate]; // 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] setWindowLevel:UIWindowLevelStatusBar]; [self dismissViewControllerAnimated:animated completion:completion]; } - (BOOL)wantsWindowToBecomeKey { return self.previousKeyWindow != nil; } - (void)toggleToolWithViewControllerProvider:(UIViewController *(^)(void))future completion:(void(^)(void))completion { if (self.presentedViewController) { [self resignKeyAndDismissViewControllerAnimated:YES completion:completion]; } else if (future) { [self makeKeyAndPresentViewController:future() animated:YES completion:completion]; } } #pragma mark - Keyboard Shortcut Helpers - (void)toggleSelectTool { if (self.currentMode == FLEXExplorerModeSelect) { self.currentMode = FLEXExplorerModeDefault; } else { self.currentMode = FLEXExplorerModeSelect; } } - (void)toggleMoveTool { if (self.currentMode == FLEXExplorerModeMove) { self.currentMode = FLEXExplorerModeDefault; } else { self.currentMode = FLEXExplorerModeMove; } } - (void)toggleViewsTool { [self toggleViewsToolWithCompletion:nil]; } - (void)toggleViewsToolWithCompletion:(void(^)(void))completion { [self toggleToolWithViewControllerProvider:^UIViewController *{ NSArray *allViews = [self allViewsInHierarchy]; NSDictionary *depthsForViews = [self hierarchyDepthsForViews:allViews]; FLEXHierarchyTableViewController *hierarchyTVC = [[FLEXHierarchyTableViewController alloc] initWithViews:allViews viewsAtTap:self.viewsAtTapPoint selectedView:self.selectedView depths:depthsForViews]; hierarchyTVC.delegate = self; return [[UINavigationController alloc] initWithRootViewController:hierarchyTVC]; } completion:^{ if (completion) { completion(); } }]; } - (void)toggleMenuTool { [self toggleToolWithViewControllerProvider:^UIViewController *{ FLEXGlobalsTableViewController *globalsViewController = [FLEXGlobalsTableViewController new]; globalsViewController.delegate = self; [FLEXGlobalsTableViewController setApplicationWindow:[UIApplication.sharedApplication keyWindow]]; return [[UINavigationController alloc] initWithRootViewController:globalsViewController]; } completion:nil]; } - (void)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]; } } } - (void)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]; } } } - (void)handleRightArrowKeyPressed { if (self.currentMode == FLEXExplorerModeMove) { CGRect frame = self.selectedView.frame; frame.origin.x += 1.0 / UIScreen.mainScreen.scale; self.selectedView.frame = frame; } } - (void)handleLeftArrowKeyPressed { if (self.currentMode == FLEXExplorerModeMove) { CGRect frame = self.selectedView.frame; frame.origin.x -= 1.0 / UIScreen.mainScreen.scale; self.selectedView.frame = frame; } } @end