Forráskód Böngészése

Add 3D view explorer

Tanner Bennett 6 éve
szülő
commit
3bde42fd21
29 módosított fájl, 2166 hozzáadás és 161 törlés
  1. 15 48
      Classes/ExplorerInterface/FLEXExplorerViewController.m
  2. 1 1
      Classes/GlobalStateExplorers/RuntimeBrowser/FLEXObjcRuntimeViewController.m
  3. 20 0
      Classes/Utility/Categories/NSMapTable+FLEX_Subscripting.h
  4. 21 0
      Classes/Utility/Categories/NSMapTable+FLEX_Subscripting.m
  5. 10 1
      Classes/Utility/FLEXUtility.h
  6. 1 1
      Classes/Utility/FLEXUtility.m
  7. 0 25
      Classes/ViewHierarchy/FLEXHierarchyTableViewController.h
  8. 26 0
      Classes/ViewHierarchy/FLEXHierarchyViewController.h
  9. 154 0
      Classes/ViewHierarchy/FLEXHierarchyViewController.m
  10. 0 2
      Classes/ViewHierarchy/FLEXImagePreviewViewController.m
  11. 22 0
      Classes/ViewHierarchy/SnapshotExplorer/FHSRangeSlider.h
  12. 201 0
      Classes/ViewHierarchy/SnapshotExplorer/FHSRangeSlider.m
  13. 46 0
      Classes/ViewHierarchy/SnapshotExplorer/FHSSnapshotView.h
  14. 304 0
      Classes/ViewHierarchy/SnapshotExplorer/FHSSnapshotView.m
  15. 35 0
      Classes/ViewHierarchy/SnapshotExplorer/FHSView.h
  16. 194 0
      Classes/ViewHierarchy/SnapshotExplorer/FHSView.m
  17. 29 0
      Classes/ViewHierarchy/SnapshotExplorer/FHSViewController.h
  18. 277 0
      Classes/ViewHierarchy/SnapshotExplorer/FHSViewController.m
  19. 37 0
      Classes/ViewHierarchy/SnapshotExplorer/FHSViewSnapshot.h
  20. 62 0
      Classes/ViewHierarchy/SnapshotExplorer/FHSViewSnapshot.m
  21. 37 0
      Classes/ViewHierarchy/SnapshotExplorer/Scene/FHSSnapshotNodes.h
  22. 90 0
      Classes/ViewHierarchy/SnapshotExplorer/Scene/FHSSnapshotNodes.m
  23. 62 0
      Classes/ViewHierarchy/SnapshotExplorer/Scene/SceneKit+Snapshot.h
  24. 278 0
      Classes/ViewHierarchy/SnapshotExplorer/Scene/SceneKit+Snapshot.m
  25. 0 0
      Classes/ViewHierarchy/TreeExplorer/FLEXHierarchyTableViewCell.h
  26. 0 0
      Classes/ViewHierarchy/TreeExplorer/FLEXHierarchyTableViewCell.m
  27. 20 0
      Classes/ViewHierarchy/TreeExplorer/FLEXHierarchyTableViewController.h
  28. 117 72
      Classes/ViewHierarchy/FLEXHierarchyTableViewController.m
  29. 107 11
      FLEX.xcodeproj/project.pbxproj

+ 15 - 48
Classes/ExplorerInterface/FLEXExplorerViewController.m

@@ -10,7 +10,7 @@
 #import "FLEXExplorerToolbar.h"
 #import "FLEXToolbarItem.h"
 #import "FLEXUtility.h"
-#import "FLEXHierarchyTableViewController.h"
+#import "FLEXHierarchyViewController.h"
 #import "FLEXGlobalsTableViewController.h"
 #import "FLEXObjectExplorerViewController.h"
 #import "FLEXObjectExplorerFactory.h"
@@ -24,7 +24,7 @@ typedef NS_ENUM(NSUInteger, FLEXExplorerMode) {
     FLEXExplorerModeMove
 };
 
-@interface FLEXExplorerViewController () <FLEXHierarchyTableViewControllerDelegate, FLEXGlobalsTableViewControllerDelegate>
+@interface FLEXExplorerViewController () <FLEXHierarchyDelegate, FLEXGlobalsTableViewControllerDelegate>
 
 @property (nonatomic) FLEXExplorerToolbar *explorerToolbar;
 
@@ -141,7 +141,7 @@ typedef NS_ENUM(NSUInteger, FLEXExplorerMode) {
 {
     UIViewController *viewControllerToAsk = [self viewControllerForRotationAndOrientation];
     UIInterfaceOrientationMask supportedOrientations = [FLEXUtility infoPlistSupportedInterfaceOrientationsMask];
-    if (viewControllerToAsk && viewControllerToAsk != self) {
+    if (viewControllerToAsk && ![viewControllerToAsk isKindOfClass:[self class]]) {
         supportedOrientations = [viewControllerToAsk supportedInterfaceOrientations];
     }
     
@@ -167,6 +167,7 @@ typedef NS_ENUM(NSUInteger, FLEXExplorerMode) {
 
 - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator
 {
+    [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
     [coordinator animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext> context) {
         for (UIView *outlineView in self.outlineViewsForVisibleViews.allValues) {
             outlineView.hidden = YES;
@@ -374,19 +375,6 @@ typedef NS_ENUM(NSUInteger, FLEXExplorerMode) {
     [self toggleViewsTool];
 }
 
-- (NSArray<UIView *> *)allViewsInHierarchy
-{
-    NSMutableArray<UIView *> *allViews = [NSMutableArray array];
-    NSArray<UIWindow *> *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"];
@@ -626,31 +614,6 @@ typedef NS_ENUM(NSUInteger, FLEXExplorerMode) {
     return subviewsAtPoint;
 }
 
-- (NSArray<UIView *> *)allRecursiveSubviewsInView:(UIView *)view
-{
-    NSMutableArray<UIView *> *subviews = [NSMutableArray array];
-    for (UIView *subview in view.subviews) {
-        [subviews addObject:subview];
-        [subviews addObjectsFromArray:[self allRecursiveSubviewsInView:subview]];
-    }
-    return subviews;
-}
-
-- (NSDictionary<NSValue *, NSNumber *> *)hierarchyDepthsForViews:(NSArray<UIView *> *)views
-{
-    NSMutableDictionary<NSValue *, NSNumber *> *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
 
@@ -738,9 +701,9 @@ typedef NS_ENUM(NSUInteger, FLEXExplorerMode) {
 }
 
 
-#pragma mark - FLEXHierarchyTableViewControllerDelegate
+#pragma mark - FLEXHierarchyDelegate
 
-- (void)hierarchyViewController:(FLEXHierarchyTableViewController *)hierarchyViewController didFinishWithSelectedView:(UIView *)selectedView
+- (void)viewHierarchyDidDismiss:(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.
@@ -866,11 +829,15 @@ typedef NS_ENUM(NSUInteger, FLEXExplorerMode) {
 - (void)toggleViewsToolWithCompletion:(void(^)(void))completion
 {
     [self toggleToolWithViewControllerProvider:^UIViewController *{
-        NSArray<UIView *> *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];
+        if (self.selectedView) {
+            return [FLEXHierarchyViewController
+                delegate:self
+                viewsAtTap:self.viewsAtTapPoint
+                selectedView:self.selectedView
+            ];
+        } else {
+            return [FLEXHierarchyViewController delegate:self];
+        }
     } completion:^{
         if (completion) {
             completion();

+ 1 - 1
Classes/GlobalStateExplorers/RuntimeBrowser/FLEXObjcRuntimeViewController.m

@@ -75,7 +75,7 @@
         make.button(@"Copy Path").handler(^(NSArray<NSString *> *strings) {
             UIPasteboard.generalPasteboard.string = path;
         });
-        make.button(@"Dismiss");
+        make.button(@"Dismiss").cancelStyle();
     } showFrom:self];
 }
 

+ 20 - 0
Classes/Utility/Categories/NSMapTable+FLEX_Subscripting.h

@@ -0,0 +1,20 @@
+//
+//  NSMapTable+FLEX_Subscripting.h
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/9/20.
+//  Copyright © 2020 Flipboard. All rights reserved.
+//
+
+#import <Foundation/Foundation.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface NSMapTable<KeyType, ObjectType> (FLEX_Subscripting)
+
+- (nullable ObjectType)objectForKeyedSubscript:(KeyType)key;
+- (void)setObject:(nullable ObjectType)obj forKeyedSubscript:(KeyType <NSCopying>)key;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 21 - 0
Classes/Utility/Categories/NSMapTable+FLEX_Subscripting.m

@@ -0,0 +1,21 @@
+//
+//  NSMapTable+FLEX_Subscripting.m
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/9/20.
+//  Copyright © 2020 Flipboard. All rights reserved.
+//
+
+#import "NSMapTable+FLEX_Subscripting.h"
+
+@implementation NSMapTable (FLEX_Subscripting)
+
+- (id)objectForKeyedSubscript:(id)key {
+    return [self objectForKey:key];
+}
+
+- (void)setObject:(id)obj forKeyedSubscript:(id<NSCopying>)key {
+    [self setObject:obj forKey:key];
+}
+
+@end

+ 10 - 1
Classes/Utility/FLEXUtility.h

@@ -14,8 +14,17 @@
 #import "FLEXAlert.h"
 #import "NSArray+Functional.h"
 #import "UIFont+FLEX.h"
+#import "NSMapTable+FLEX_Subscripting.h"
 
-#define FLEXFloor(x) (floor(UIScreen.mainScreen.scale * (x)) / UIScreen.mainScreen.scale)
+/// Rounds down to the nearest "point" coordinate
+NS_INLINE CGFloat FLEXFloor(CGFloat x) {
+    return floor(UIScreen.mainScreen.scale * (x)) / UIScreen.mainScreen.scale;
+}
+
+/// Creates a CGRect with all members rounded down to the nearest "point" coordinate
+NS_INLINE CGRect FLEXRectMake(CGFloat x, CGFloat y, CGFloat width, CGFloat height) {
+    return CGRectMake(FLEXFloor(x), FLEXFloor(y), FLEXFloor(width), FLEXFloor(height));
+}
 
 #ifdef __IPHONE_13_0
 #define FLEX_AT_LEAST_IOS13_SDK (__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_13_0)

+ 1 - 1
Classes/Utility/FLEXUtility.m

@@ -48,7 +48,7 @@
 
 + (UIViewController *)viewControllerForView:(UIView *)view
 {
-    NSString *viewDelegate = @"viewDelegate";
+    NSString *viewDelegate = @"_viewDelegate";
     if ([view respondsToSelector:NSSelectorFromString(viewDelegate)]) {
         return [view valueForKey:viewDelegate];
     }

+ 0 - 25
Classes/ViewHierarchy/FLEXHierarchyTableViewController.h

@@ -1,25 +0,0 @@
-//
-//  FLEXHierarchyTableViewController.h
-//  Flipboard
-//
-//  Created by Ryan Olson on 2014-05-01.
-//  Copyright (c) 2014 Flipboard. All rights reserved.
-//
-
-#import "FLEXTableViewController.h"
-
-@protocol FLEXHierarchyTableViewControllerDelegate;
-
-@interface FLEXHierarchyTableViewController : FLEXTableViewController
-
-- (instancetype)initWithViews:(NSArray<UIView *> *)allViews viewsAtTap:(NSArray<UIView *> *)viewsAtTap selectedView:(UIView *)selectedView depths:(NSDictionary<NSValue *, NSNumber *> *)depthsForViews;
-
-@property (nonatomic, weak) id <FLEXHierarchyTableViewControllerDelegate> delegate;
-
-@end
-
-@protocol FLEXHierarchyTableViewControllerDelegate <NSObject>
-
-- (void)hierarchyViewController:(FLEXHierarchyTableViewController *)hierarchyViewController didFinishWithSelectedView:(UIView *)selectedView;
-
-@end

+ 26 - 0
Classes/ViewHierarchy/FLEXHierarchyViewController.h

@@ -0,0 +1,26 @@
+//
+//  FLEXHierarchyViewController.h
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/9/20.
+//  Copyright © 2020 Flipboard. All rights reserved.
+//
+
+#import <UIKit/UIKit.h>
+
+@protocol FLEXHierarchyDelegate <NSObject>
+- (void)viewHierarchyDidDismiss:(UIView *)selectedView;
+@end
+
+/// A navigation controller which manages two child view controllers:
+/// a 3D Reveal-like hierarchy explorer, and a 2D tree-list hierarchy explorer.
+@interface FLEXHierarchyViewController : UINavigationController
+
++ (instancetype)delegate:(id<FLEXHierarchyDelegate>)delegate;
++ (instancetype)delegate:(id<FLEXHierarchyDelegate>)delegate
+              viewsAtTap:(NSArray<UIView *> *)viewsAtTap
+            selectedView:(UIView *)selectedView;
+
+- (void)toggleHierarchyMode;
+
+@end

+ 154 - 0
Classes/ViewHierarchy/FLEXHierarchyViewController.m

@@ -0,0 +1,154 @@
+//
+//  FLEXHierarchyViewController.m
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/9/20.
+//  Copyright © 2020 Flipboard. All rights reserved.
+//
+
+#import "FLEXHierarchyViewController.h"
+#import "FLEXHierarchyTableViewController.h"
+#import "FHSViewController.h"
+#import "FLEXUtility.h"
+
+typedef NS_ENUM(NSUInteger, FLEXHierarchyViewMode) {
+    FLEXHierarchyViewModeTree = 1,
+    FLEXHierarchyViewMode3DSnapshot
+};
+
+@interface FLEXHierarchyViewController ()
+@property (nonatomic, readonly, weak) id<FLEXHierarchyDelegate> hierarchyDelegate;
+@property (nonatomic, readonly) FHSViewController *snapshotViewController;
+@property (nonatomic, readonly) FLEXHierarchyTableViewController *treeViewController;
+
+@property (nonatomic) FLEXHierarchyViewMode mode;
+
+@property (nonatomic, readonly) UIView *selectedView;
+@end
+
+@implementation FLEXHierarchyViewController
+
+#pragma mark - Initialization
+
++ (instancetype)delegate:(id<FLEXHierarchyDelegate>)delegate {
+    return [self delegate:delegate viewsAtTap:nil selectedView:nil];
+}
+
++ (instancetype)delegate:(id<FLEXHierarchyDelegate>)delegate
+              viewsAtTap:(NSArray<UIView *> *)viewsAtTap
+            selectedView:(UIView *)selectedView {
+    return [[self alloc] initWithDelegate:delegate viewsAtTap:viewsAtTap selectedView:selectedView];
+}
+
+- (id)initWithDelegate:(id)delegate viewsAtTap:(NSArray<UIView *> *)viewsAtTap selectedView:(UIView *)view {
+    self = [super init];
+    if (self) {
+        NSArray<UIWindow *> *allWindows = [FLEXUtility allWindows];
+        _hierarchyDelegate = delegate;
+        _treeViewController = [FLEXHierarchyTableViewController
+            windows:allWindows viewsAtTap:viewsAtTap selectedView:view
+        ];
+
+        if (viewsAtTap) {
+            _snapshotViewController = [FHSViewController snapshotViewsAtTap:viewsAtTap selectedView:view];
+        } else {
+            _snapshotViewController = [FHSViewController snapshotWindows:allWindows];
+        }
+
+        self.modalPresentationStyle = UIModalPresentationFullScreen;
+    }
+
+    return self;
+}
+
+
+#pragma mark - Lifecycle
+
+- (void)viewDidLoad {
+    [super viewDidLoad];
+
+    // 3D toggle button
+    self.treeViewController.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc]
+        initWithTitle:@"3D" style:UIBarButtonItemStylePlain target:self action:@selector(toggleHierarchyMode)
+    ];
+
+    // Dismiss when tree view row is selected
+    __weak id<FLEXHierarchyDelegate> delegate = self.hierarchyDelegate;
+    self.treeViewController.didSelectRowAction = ^(UIView *selectedView) {
+        [delegate viewHierarchyDidDismiss:selectedView];
+    };
+
+    // Start of in tree view
+    _mode = FLEXHierarchyViewModeTree;
+    [self pushViewController:self.treeViewController animated:NO];
+}
+
+- (void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated {
+    // Done button
+    viewController.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc]
+        initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(donePressed)
+    ];
+
+    [super pushViewController:viewController animated:animated];
+}
+
+
+#pragma mark - Private
+
+- (void)donePressed {
+    [self.hierarchyDelegate viewHierarchyDidDismiss:self.selectedView];
+}
+
+- (void)toggleHierarchyMode {
+    switch (self.mode) {
+        case FLEXHierarchyViewModeTree:
+            self.mode = FLEXHierarchyViewMode3DSnapshot;
+            break;
+        case FLEXHierarchyViewMode3DSnapshot:
+            self.mode = FLEXHierarchyViewModeTree;
+            break;
+    }
+}
+
+- (void)setMode:(FLEXHierarchyViewMode)mode {
+    if (mode != _mode) {
+        _mode = mode;
+
+        // The tree view controller is our top stack view controller, and
+        // changing the mode simply pushes the snapshot view. In the future,
+        // I would like to have the 3D toggle button transparently switch
+        // between two views instead of pushing a new view controller.
+        // This way the views should share the search controller somehow.
+        switch (mode) {
+            case FLEXHierarchyViewModeTree:
+                [self popViewControllerAnimated:NO];
+                self.toolbarHidden = YES;
+                break;
+            case FLEXHierarchyViewMode3DSnapshot:
+                [self pushViewController:self.snapshotViewController animated:NO];
+                self.toolbarHidden = NO;
+                break;
+        }
+    }
+}
+
+- (UIView *)selectedView {
+    switch (self.mode) {
+        case FLEXHierarchyViewModeTree:
+            return self.treeViewController.selectedView;
+        case FLEXHierarchyViewMode3DSnapshot:
+            return self.snapshotViewController.selectedView;
+    }
+}
+
+
+//#pragma mark - FLEXSearchResultsUpdating
+//
+//- (void)updateSearchResults:(NSString *)newText {
+//    [self.treeViewController updateSearchResults:newText];
+//
+//    // TODO: switch between views at tap and full hierarchy
+//    [self.snapshotViewController updateSearchResults:newText];
+//}
+
+@end

+ 0 - 2
Classes/ViewHierarchy/FLEXImagePreviewViewController.m

@@ -103,8 +103,6 @@
     UIActivityViewController *activityVC = [[UIActivityViewController alloc] initWithActivityItems:@[self.image] applicationActivities:@[]];
     
     if (!canSaveToCameraRoll && !didShowWarning) {
-        activityVC.excludedActivityTypes = @[UIActivityTypeSaveToCameraRoll];
-
         didShowWarning = YES;
         NSString *msg = @"Add 'NSPhotoLibraryUsageDescription' to this app's Info.plist to save images.";
         [FLEXAlert makeAlert:^(FLEXAlert *make) {

+ 22 - 0
Classes/ViewHierarchy/SnapshotExplorer/FHSRangeSlider.h

@@ -0,0 +1,22 @@
+//
+//  FHSRangeSlider.h
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/7/20.
+//  Copyright © 2020 Flipboard. All rights reserved.
+//
+
+#import <UIKit/UIKit.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FHSRangeSlider : UIControl
+
+@property (nonatomic) CGFloat allowedMinValue;
+@property (nonatomic) CGFloat allowedMaxValue;
+@property (nonatomic) CGFloat minValue;
+@property (nonatomic) CGFloat maxValue;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 201 - 0
Classes/ViewHierarchy/SnapshotExplorer/FHSRangeSlider.m

@@ -0,0 +1,201 @@
+//
+//  FHSRangeSlider.m
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/7/20.
+//  Copyright © 2020 Flipboard. All rights reserved.
+//
+
+#import "FHSRangeSlider.h"
+#import "FLEXResources.h"
+#import "FLEXUtility.h"
+
+@interface FHSRangeSlider ()
+@property (nonatomic, readonly) UIImageView *track;
+@property (nonatomic, readonly) UIImageView *fill;
+@property (nonatomic, readonly) UIImageView *leftHandle;
+@property (nonatomic, readonly) UIImageView *rightHandle;
+
+@property (nonatomic, getter=isTrackingLeftHandle) BOOL trackingLeftHandle;
+@property (nonatomic, getter=isTrackingRightHandle) BOOL trackingRightHandle;
+@end
+
+@implementation FHSRangeSlider
+
+#pragma mark - Initialization
+
+- (instancetype)initWithFrame:(CGRect)frame {
+    self = [super initWithFrame:frame];
+    if (self) {
+        _allowedMaxValue = 1.f;
+        _maxValue = 1.f;
+        [self initSubviews];
+    }
+
+    return self;
+}
+
+- (void)initSubviews {
+    self.userInteractionEnabled = YES;
+    UIImageView * (^newSubviewImageView)() = ^UIImageView *(UIImage *image) {
+        UIImageView *iv = [UIImageView new];
+        iv.image = image;
+//        iv.userInteractionEnabled = YES;
+        [self addSubview:iv];
+        return iv;
+    };
+
+    _track = newSubviewImageView([FLEXResources rangeSliderTrack]);
+    _fill = newSubviewImageView([FLEXResources rangeSliderFill]);
+    _leftHandle = newSubviewImageView([FLEXResources rangeSliderLeftHandle]);
+    _rightHandle = newSubviewImageView([FLEXResources rangeSliderRightHandle]);
+}
+
+#pragma mark - Setters / Private
+
+- (CGFloat)valueAt:(CGFloat)x {
+    CGFloat minX = self.leftHandle.image.size.width;
+    CGFloat maxX = self.bounds.size.width - self.rightHandle.image.size.width;
+    CGFloat cappedX = MIN(MAX(x, minX), maxX);
+    CGFloat delta = maxX - minX;
+    CGFloat maxDelta = self.allowedMaxValue - self.allowedMinValue;
+
+    return ((delta > 0) ? (cappedX - minX) / delta : 0) * maxDelta + self.allowedMinValue;
+}
+
+- (void)setAllowedMinValue:(CGFloat)allowedMinValue {
+    _allowedMinValue = allowedMinValue;
+
+    if (self.minValue < self.allowedMaxValue) {
+        self.minValue = self.allowedMaxValue;
+    } else {
+        [self setNeedsLayout];
+    }
+}
+
+- (void)setAllowedMaxValue:(CGFloat)allowedMaxValue {
+    _allowedMaxValue = allowedMaxValue;
+
+    if (self.maxValue > self.allowedMaxValue) {
+        self.maxValue = self.allowedMaxValue;
+    } else {
+        [self valuesChanged:NO];
+    }
+}
+
+- (void)setMinValue:(CGFloat)minValue {
+    _minValue = minValue;
+    [self valuesChanged:YES];
+}
+
+- (void)setMaxValue:(CGFloat)maxValue {
+    _maxValue = maxValue;
+    [self valuesChanged:YES];
+}
+
+- (void)valuesChanged:(BOOL)sendActions {
+    if (NSThread.isMainThread) {
+        if (sendActions) {
+            [self sendActionsForControlEvents:UIControlEventValueChanged];
+        }
+        [self setNeedsLayout];
+    }
+}
+
+#pragma mark - Overrides
+
+- (CGSize)intrinsicContentSize {
+    return CGSizeMake(UIViewNoIntrinsicMetric, self.leftHandle.image.size.height);
+}
+
+- (void)layoutSubviews {
+    [super layoutSubviews];
+
+    CGSize lhs = self.leftHandle.image.size;
+    CGSize rhs = self.rightHandle.image.size;
+    CGSize trackSize = self.track.image.size;
+
+    CGFloat delta = self.allowedMaxValue - self.allowedMinValue;
+    CGFloat minPercent, maxPercent;
+
+    if (delta <= 0) {
+        minPercent = maxPercent = 0;
+    } else {
+        minPercent = MAX(0, (self.minValue - self.allowedMinValue) / delta);
+        maxPercent = MAX(minPercent, (self.maxValue - self.allowedMinValue) / delta);
+    }
+
+    CGFloat rangeSliderWidth = self.bounds.size.width - lhs.width - rhs.width;
+
+    self.leftHandle.frame = FLEXRectMake(
+        rangeSliderWidth * minPercent,
+        CGRectGetMidY(self.bounds) - (lhs.height / 2.f) + 3.f,
+        lhs.width,
+        lhs.height
+    );
+
+    self.rightHandle.frame = FLEXRectMake(
+        lhs.width + (rangeSliderWidth * maxPercent),
+        CGRectGetMidY(self.bounds) - (rhs.height / 2.f) + 3.f,
+        rhs.width,
+        rhs.height
+    );
+
+    self.track.frame = FLEXRectMake(
+        lhs.width / 2.f,
+        CGRectGetMidY(self.bounds) - trackSize.height / 2.f,
+        self.bounds.size.width - (lhs.width / 2.f) - (rhs.width / 2.f),
+        trackSize.height
+    );
+
+    self.fill.frame = FLEXRectMake(
+        CGRectGetMidX(self.leftHandle.frame),
+        CGRectGetMinY(self.track.frame),
+        CGRectGetMidX(self.rightHandle.frame) - CGRectGetMidX(self.leftHandle.frame),
+        self.track.frame.size.height
+    );
+}
+
+- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event {
+    CGPoint loc = [touch locationInView:self];
+
+    if (CGRectContainsPoint(self.leftHandle.frame, loc)) {
+        self.trackingLeftHandle = YES;
+        self.trackingRightHandle = NO;
+    } else if (CGRectContainsPoint(self.rightHandle.frame, loc)) {
+        self.trackingLeftHandle = NO;
+        self.trackingRightHandle = YES;
+    } else {
+        return NO;
+    }
+
+    return YES;
+}
+
+- (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event {
+    CGPoint loc = [touch locationInView:self];
+
+    if (self.isTrackingLeftHandle) {
+        self.minValue = MIN(MAX(self.allowedMinValue, [self valueAt:loc.x]), self.maxValue);
+    } else if (self.isTrackingRightHandle) {
+        self.maxValue = MAX(MIN(self.allowedMaxValue, [self valueAt:loc.x]), self.minValue);
+    } else {
+        return NO;
+    }
+
+    [self setNeedsLayout];
+    [self layoutIfNeeded];
+
+    return YES;
+}
+
+- (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event {
+    self.trackingLeftHandle = NO;
+    self.trackingRightHandle = NO;
+}
+
+- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer {
+    return NO;
+}
+
+@end

+ 46 - 0
Classes/ViewHierarchy/SnapshotExplorer/FHSSnapshotView.h

@@ -0,0 +1,46 @@
+//
+//  FHSSnapshotView.h
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/7/20.
+//  Copyright © 2020 Flipboard. All rights reserved.
+//
+
+#import "FHSViewSnapshot.h"
+#import "FHSRangeSlider.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@protocol FHSSnapshotViewDelegate <NSObject>
+
+- (void)didSelectView:(FHSViewSnapshot *)snapshot;
+- (void)didDeselectView:(FHSViewSnapshot *)snapshot;
+- (void)didLongPressView:(FHSViewSnapshot *)snapshot;
+
+@end
+
+@interface FHSSnapshotView : UIView
+
++ (instancetype)delegate:(id<FHSSnapshotViewDelegate>)delegate;
+
+@property (nonatomic, weak) id<FHSSnapshotViewDelegate> delegate;
+
+@property (nonatomic) NSArray<FHSViewSnapshot *> *snapshots;
+@property (nonatomic, nullable) FHSViewSnapshot *selectedView;
+
+/// Views of these classes will have their headers hidden
+@property (nonatomic) NSArray<Class> *headerExclusions;
+
+@property (nonatomic, readonly) UISlider *spacingSlider;
+@property (nonatomic, readonly) FHSRangeSlider *depthSlider;
+
+- (void)emphasizeViews:(NSArray<UIView *> *)emphasizedViews;
+
+- (void)toggleShowHeaders;
+- (void)toggleShowBorders;
+
+- (void)hideView:(FHSViewSnapshot *)view;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 304 - 0
Classes/ViewHierarchy/SnapshotExplorer/FHSSnapshotView.m

@@ -0,0 +1,304 @@
+//
+//  FHSSnapshotView.m
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/7/20.
+//  Copyright © 2020 Flipboard. All rights reserved.
+//
+
+#import "FHSSnapshotView.h"
+#import "FHSSnapshotNodes.h"
+#import "SceneKit+Snapshot.h"
+#import "FLEXColor.h"
+
+@interface FHSSnapshotView ()
+@property (nonatomic, readonly) SCNView *sceneView;
+@property (nonatomic) NSString *currentSummary;
+
+/// Maps nodes by snapshot IDs
+@property (nonatomic) NSDictionary<NSString *, FHSSnapshotNodes *> *nodesMap;
+@property (nonatomic) NSInteger maxDepth;
+
+@property (nonatomic) FHSSnapshotNodes *highlightedNodes;
+@property (nonatomic, getter=wantsHideHeaders) BOOL hideHeaders;
+@property (nonatomic, getter=wantsHideBorders) BOOL hideBorders;
+@property (nonatomic) BOOL suppressSelectionEvents;
+
+@property (nonatomic, readonly) BOOL mustHideHeaders;
+@end
+
+@implementation FHSSnapshotView
+
+#pragma mark - Initialization
+
++ (instancetype)delegate:(id<FHSSnapshotViewDelegate>)delegate {
+    FHSSnapshotView *view = [self new];
+    view.delegate = delegate;
+    return view;
+}
+
+- (id)initWithFrame:(CGRect)frame {
+    self = [super initWithFrame:CGRectZero];
+    if (self) {
+        [self initSpacingSlider];
+        [self initDepthSlider];
+        [self initSceneView]; // Must be last; calls setMaxDepth
+//        self.hideHeaders = YES;
+        
+            // Self
+        self.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
+
+        // Scene
+        self.sceneView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
+        [self addGestureRecognizer:[[UITapGestureRecognizer alloc]
+            initWithTarget:self action:@selector(handleTap:)
+        ]];
+    }
+
+    return self;
+}
+
+- (void)initSceneView {
+    _sceneView = [SCNView new];
+    self.sceneView.allowsCameraControl = YES;
+
+    [self addSubview:self.sceneView];
+}
+
+- (void)initSpacingSlider {
+    _spacingSlider = [UISlider new];
+    self.spacingSlider.minimumValue = 0;
+    self.spacingSlider.maximumValue = 100;
+    self.spacingSlider.continuous = YES;
+    [self.spacingSlider
+        addTarget:self
+        action:@selector(spacingSliderDidChange:)
+        forControlEvents:UIControlEventValueChanged
+    ];
+
+    self.spacingSlider.value = 50;
+}
+
+- (void)initDepthSlider {
+    _depthSlider = [FHSRangeSlider new];
+    [self.depthSlider
+        addTarget:self
+        action:@selector(depthSliderDidChange:)
+        forControlEvents:UIControlEventValueChanged
+    ];
+}
+
+
+#pragma mark - Public
+
+- (void)setSelectedView:(FHSViewSnapshot *)view {
+    // Ivar set in selectSnapshot:
+    [self selectSnapshot:view ? self.nodesMap[view.view.identifier] : nil];
+}
+
+- (void)setSnapshots:(NSArray<FHSViewSnapshot *> *)snapshots {
+    _snapshots = snapshots;
+
+    // Create new scene (possibly discarding old scene)
+    SCNScene *scene = [SCNScene new];
+    scene.background.contents = [FLEXColor primaryBackgroundColor];
+    self.sceneView.scene = scene;
+
+    NSInteger depth = 0;
+    NSMutableDictionary *nodesMap = [NSMutableDictionary new];
+
+    // Add every root snapshot to the root scene node with increasing depths
+    SCNNode *root = scene.rootNode;
+    for (FHSViewSnapshot *snapshot in self.snapshots) {
+        [SCNNode
+            snapshot:snapshot
+            parent:nil
+            parentNode:nil
+            root:root
+            depth:&depth
+            nodesMap:nodesMap
+            hideHeaders:_hideHeaders
+        ];
+    }
+
+    self.maxDepth = depth;
+    self.nodesMap = nodesMap;
+}
+
+- (void)setHeaderExclusions:(NSArray<Class> *)headerExclusions {
+    _headerExclusions = headerExclusions;
+
+    if (headerExclusions.count) {
+        for (FHSSnapshotNodes *nodes in self.nodesMap.allValues) {
+            if ([headerExclusions containsObject:nodes.snapshotItem.view.view.class]) {
+                nodes.forceHideHeader = YES;
+            } else {
+                nodes.forceHideHeader = NO;
+            }
+        }
+    }
+}
+
+- (void)emphasizeViews:(NSArray<UIView *> *)emphasizedViews {
+    if (emphasizedViews.count) {
+        [self emphasizeViews:emphasizedViews inSnapshots:self.snapshots];
+        [self setNeedsLayout];
+    }
+}
+
+- (void)emphasizeViews:(NSArray<UIView *> *)emphasizedViews inSnapshots:(NSArray<FHSViewSnapshot *> *)snapshots {
+    for (FHSViewSnapshot *snapshot in snapshots) {
+        FHSSnapshotNodes *nodes = self.nodesMap[snapshot.view.identifier];
+        nodes.dimmed = ![emphasizedViews containsObject:snapshot.view.view];
+        [self emphasizeViews:emphasizedViews inSnapshots:snapshot.children];
+    }
+}
+
+- (void)toggleShowHeaders {
+    self.hideHeaders = !self.hideHeaders;
+}
+
+- (void)toggleShowBorders {
+    self.hideBorders = !self.hideBorders;
+}
+
+- (void)hideView:(FHSViewSnapshot *)view {
+    NSParameterAssert(view);
+    FHSSnapshotNodes *nodes = self.nodesMap[view.view.identifier];
+    [nodes.snapshot removeFromParentNode];
+}
+
+#pragma mark - Helper
+
+- (BOOL)mustHideHeaders {
+    return self.spacingSlider.value <= kFHSSmallZOffset;
+}
+
+- (void)setMaxDepth:(NSInteger)maxDepth {
+    _maxDepth = maxDepth;
+
+    self.depthSlider.allowedMinValue = 0;
+    self.depthSlider.allowedMaxValue = maxDepth;
+    self.depthSlider.maxValue = maxDepth;
+    self.depthSlider.minValue = 0;
+}
+
+- (void)setHideHeaders:(BOOL)hideHeaders {
+    if (_hideHeaders != hideHeaders) {
+        _hideHeaders = hideHeaders;
+
+        if (!self.mustHideHeaders) {
+            if (hideHeaders) {
+                [self hideHeaders];
+            } else {
+                [self unhideHeaders];
+            }
+        }
+    }
+}
+
+- (void)setHideBorders:(BOOL)hideBorders {
+    if (_hideBorders != hideBorders) {
+        _hideBorders = hideBorders;
+
+        for (FHSSnapshotNodes *nodes in self.nodesMap.allValues) {
+            nodes.border.hidden = hideBorders;
+        }
+    }
+}
+
+- (FHSSnapshotNodes *)nodesAtPoint:(CGPoint)point {
+    NSArray<SCNHitTestResult *> *results = [self.sceneView hitTest:point options:nil];
+    for (SCNHitTestResult *result in results) {
+        SCNNode *nearestSnapshot = result.node.nearestAncestorSnapshot;
+        if (nearestSnapshot) {
+            return self.nodesMap[nearestSnapshot.name];
+        }
+    }
+
+    return nil;
+}
+
+- (void)selectSnapshot:(FHSSnapshotNodes *)selected {
+    // Notify delegate of de-select
+    if (!selected && self.selectedView) {
+        [self.delegate didDeselectView:self.selectedView];
+    }
+
+    _selectedView = selected.snapshotItem;
+
+    // Case: selected the currently selected node
+    if (selected == self.highlightedNodes) {
+        return;
+    }
+
+    // No-op if nothng is selected (yay objc!)
+    self.highlightedNodes.highlighted = NO;
+    self.highlightedNodes = nil;
+
+    // No node means we tapped the background
+    if (selected) {
+        selected.highlighted = YES;
+        // TODO: update description text here
+        self.highlightedNodes = selected;
+    }
+
+    // Notify delegate
+    [self.delegate didSelectView:selected.snapshotItem];
+
+    [self setNeedsLayout];
+}
+
+- (void)hideHeaders {
+    for (FHSSnapshotNodes *nodes in self.nodesMap.allValues) {
+        nodes.header.hidden = YES;
+    }
+}
+
+- (void)unhideHeaders {
+    for (FHSSnapshotNodes *nodes in self.nodesMap.allValues) {
+        if (!nodes.forceHideHeader) {
+            nodes.header.hidden = NO;
+        }
+    }
+}
+
+
+#pragma mark - Event Handlers
+
+- (void)handleTap:(UITapGestureRecognizer *)gesture {
+    if (gesture.state == UIGestureRecognizerStateRecognized) {
+        CGPoint tap = [gesture locationInView:self.sceneView];
+        [self selectSnapshot:[self nodesAtPoint:tap]];
+    }
+}
+
+- (void)spacingSliderDidChange:(UISlider *)slider {
+    // TODO: hiding the header when flat logic
+
+    for (FHSSnapshotNodes *nodes in self.nodesMap.allValues) {
+        nodes.snapshot.position = ({
+            SCNVector3 pos = nodes.snapshot.position;
+            pos.z = MAX(slider.value, kFHSSmallZOffset) * nodes.depth;
+            pos;
+        });
+
+        if (!self.wantsHideHeaders) {
+            if (self.mustHideHeaders) {
+                [self hideHeaders];
+            } else {
+                [self unhideHeaders];
+            }
+        }
+    }
+}
+
+- (void)depthSliderDidChange:(FHSRangeSlider *)slider {
+    CGFloat min = slider.minValue, max = slider.maxValue;
+    for (FHSSnapshotNodes *nodes in self.nodesMap.allValues) {
+        CGFloat depth = self.maxDepth - nodes.depth;
+        nodes.snapshot.hidden = depth < min || max < depth;
+    }
+}
+
+@end

+ 35 - 0
Classes/ViewHierarchy/SnapshotExplorer/FHSView.h

@@ -0,0 +1,35 @@
+//
+//  FHSView.h
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/6/20.
+//
+
+#import <UIKit/UIKit.h>
+
+@interface FHSView : NSObject {
+    @private
+    BOOL _inScrollView;
+}
+
++ (instancetype)forView:(UIView *)view isInScrollView:(BOOL)inScrollView;
+
+/// Intentionally not weak
+@property (nonatomic, readonly) UIView *view;
+@property (nonatomic, readonly) NSString *identifier;
+
+@property (nonatomic, readonly) NSString *title;
+/// Whether or not this view item should be visually distinguished
+@property (nonatomic, readwrite) BOOL important;
+
+@property (nonatomic, readonly) CGRect frame;
+@property (nonatomic, readonly) BOOL hidden;
+@property (nonatomic, readonly) UIImage *snapshotImage;
+
+@property (nonatomic, readonly) NSArray<FHSView *> *children;
+@property (nonatomic, readonly) NSString *summary;
+
+/// @return importantAttr if .important, otherwise normalAttr
+//- (id)ifImportant:(id)importantAttr ifNormal:(id)normalAttr;
+
+@end

+ 194 - 0
Classes/ViewHierarchy/SnapshotExplorer/FHSView.m

@@ -0,0 +1,194 @@
+//
+//  FHSView.m
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/6/20.
+//
+
+#import "FHSView.h"
+#import "FLEXUtility.h"
+#import "NSArray+Functional.h"
+
+@interface FHSView (Snapshotting)
++ (UIImage *)_snapshotView:(UIView *)view;
+@end
+
+@implementation FHSView
+
++ (instancetype)forView:(UIView *)view isInScrollView:(BOOL)inScrollView {
+    return [[self alloc] initWithView:view isInScrollView:inScrollView];
+}
+
+- (id)initWithView:(UIView *)view isInScrollView:(BOOL)inScrollView {
+    self = [super init];
+    if (self) {
+        _view = view;
+        _inScrollView = inScrollView;
+        _identifier = [NSUUID UUID].UUIDString;
+
+        UIViewController *controller = [FLEXUtility viewControllerForView:view];
+        if (controller) {
+            _important = YES;
+            _title = [NSString stringWithFormat:
+                @"%@ (for %@)",
+                NSStringFromClass([controller class]),
+                NSStringFromClass([view class])
+            ];
+        } else {
+            _title = NSStringFromClass([view class]);
+        }
+    }
+
+    return self;
+}
+
+- (CGRect)frame {
+    if (_inScrollView) {
+        CGPoint offset = [(UIScrollView *)self.view.superview contentOffset];
+        return CGRectOffset(self.view.frame, -offset.x, -offset.y);
+    } else {
+        return self.view.frame;
+    }
+}
+
+- (BOOL)hidden {
+    return self.view.isHidden;
+}
+
+- (UIImage *)snapshotImage {
+    return [FHSView _snapshotView:self.view];
+}
+
+- (NSArray<FHSView *> *)children {
+    BOOL isScrollView = [self.view isKindOfClass:[UIScrollView class]];
+    return [self.view.subviews flex_mapped:^id(UIView *subview, NSUInteger idx) {
+        return [FHSView forView:subview isInScrollView:isScrollView];
+    }];
+}
+
+- (NSString *)summary {
+    CGRect f = self.frame;
+    return [NSString stringWithFormat:
+        @"%@ (%.1f, %.1f, %.1f, %.1f)",
+        NSStringFromClass([self.view class]),
+        f.origin.x, f.origin.y, f.size.width, f.size.height
+    ];
+}
+
+- (NSString *)description{
+    return self.view.description;
+}
+
+- (id)ifImportant:(id)importantAttr ifNormal:(id)normalAttr {
+    return self.important ? importantAttr : normalAttr;
+}
+
+@end
+
+@implementation FHSView (Snapshotting)
+
++ (UIImage *)drawView:(UIView *)view {
+    UIGraphicsBeginImageContextWithOptions(view.bounds.size, NO, 0);
+    [view drawViewHierarchyInRect:view.bounds afterScreenUpdates:YES];
+    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
+    UIGraphicsEndImageContext();
+    return image;
+}
+
+/// Recursively hides all views that may be obscuring the given view and collects them
+/// in the given array. You should unhide them all when you are done.
++ (BOOL)_hideViewsCoveringView:(UIView *)view
+                          root:(UIView *)rootView
+                   hiddenViews:(NSMutableArray<UIView *> *)hiddenViews {
+    // Stop when we reach this view
+    if (view == rootView) {
+        return YES;
+    }
+
+    for (UIView *subview in rootView.subviews.reverseObjectEnumerator.allObjects) {
+        if ([self _hideViewsCoveringView:view root:subview hiddenViews:hiddenViews]) {
+            return YES;
+        }
+    }
+
+    if (!rootView.isHidden) {
+        rootView.hidden = YES;
+        [hiddenViews addObject:rootView];
+    }
+
+    return NO;
+}
+
+
+/// Recursively hides all views that may be obscuring the given view and collects them
+/// in the given array. You should unhide them all when you are done.
++ (void)hideViewsCoveringView:(UIView *)view doWhileHidden:(void(^)())block {
+    NSMutableArray *viewsToUnhide = [NSMutableArray new];
+    if ([self _hideViewsCoveringView:view root:view.window hiddenViews:viewsToUnhide]) {
+        block();
+    }
+
+    for (UIView *v in viewsToUnhide) {
+        v.hidden = NO;
+    }
+}
+
++ (UIImage *)_snapshotVisualEffectBackdropView:(UIView *)view {
+    NSParameterAssert(view.window);
+
+    // UIVisualEffectView is a special case that cannot be snapshotted
+    // the same way as any other view. From Apple docs:
+    //
+    //   Many effects require support from the window that hosts the
+    //   UIVisualEffectView. Attempting to take a snapshot of only the
+    //   UIVisualEffectView will result in a snapshot that does not
+    //   contain the effect. To take a snapshot of a view hierarchy
+    //   that contains a UIVisualEffectView, you must take a snapshot
+    //   of the entire UIWindow or UIScreen that contains it.
+    //
+    // To snapshot this view, we traverse the view hierarchy starting
+    // from the window and hide any views that are on top of the
+    // _UIVisualEffectBackdropView so that it is visible in a snapshot
+    // of the window. We then take a snapshot of the window and crop
+    // it to the part that contains the backdrop view. This appears to
+    // be the same technique that Xcode's own view debugger uses to
+    // snapshot visual effect views.
+    __block UIImage *image = nil;
+    [self hideViewsCoveringView:view doWhileHidden:^{
+        image = [self drawView:view];
+        CGRect cropRect = [view.window convertRect:view.bounds fromView:view];
+        image = [UIImage imageWithCGImage:CGImageCreateWithImageInRect(image.CGImage, cropRect)];
+    }];
+
+    return image;
+}
+
++ (UIImage *)_snapshotView:(UIView *)view {
+    UIView *superview = view.superview;
+    // Is this view inside a UIVisualEffectView?
+    if ([superview isKindOfClass:[UIVisualEffectView class]]) {
+        // Is it (probably) the "backdrop" view of this UIVisualEffectView?
+        if (superview.subviews.firstObject == view) {
+            return [self _snapshotVisualEffectBackdropView:view];
+        }
+    }
+
+    // Hide the view's subviews before we snapshot it
+    NSMutableIndexSet *toUnhide = [NSMutableIndexSet new];
+    [view.subviews flex_forEach:^(UIView *v, NSUInteger idx) {
+        if (!v.isHidden) {
+            v.hidden = YES;
+            [toUnhide addIndex:idx];
+        }
+    }];
+
+    // Snapshot the view, then unhide the previously-unhidden views
+    UIImage *snapshot = [self drawView:view];
+    for (UIView *v in [view.subviews objectsAtIndexes:toUnhide]) {
+        v.hidden = NO;
+    }
+
+    return snapshot;
+}
+
+@end

+ 29 - 0
Classes/ViewHierarchy/SnapshotExplorer/FHSViewController.h

@@ -0,0 +1,29 @@
+//
+//  FHSViewController.h
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/6/20.
+//  Copyright © 2020 Flipboard. All rights reserved.
+//
+
+#import <UIKit/UIKit.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// The view controller
+/// "FHS" stands for "FLEX (view) hierarchy snapshot"
+@interface FHSViewController : UIViewController
+
+/// Use this when you want to snapshot a set of windows.
++ (instancetype)snapshotWindows:(NSArray<UIWindow *> *)windows;
+/// Use this when you want to snapshot a specific slice of the view hierarchy.
++ (instancetype)snapshotView:(UIView *)view;
+/// Use this when you want to emphasize specific views on the screen.
+/// These views must all be in the same window as the selected view.
++ (instancetype)snapshotViewsAtTap:(NSArray<UIView *> *)viewsAtTap selectedView:(UIView *)view;
+
+@property (nonatomic, nullable) UIView *selectedView;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 277 - 0
Classes/ViewHierarchy/SnapshotExplorer/FHSViewController.m

@@ -0,0 +1,277 @@
+//
+//  FHSViewController.m
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/6/20.
+//  Copyright © 2020 Flipboard. All rights reserved.
+//
+
+#import "FHSViewController.h"
+#import "FHSSnapshotView.h"
+#import "FLEXColor.h"
+#import "FLEXAlert.h"
+#import "NSArray+Functional.h"
+#import "FLEXHierarchyViewController.h"
+#import "FLEXWindow.h"
+
+BOOL const kFHSViewControllerExcludeFLEXWindows = YES;
+
+@interface FHSViewController () <FHSSnapshotViewDelegate>
+/// An array of only the target views whose hierarchies
+/// we wish to snapshot, not every view in the snapshot.
+@property (nonatomic, readonly) NSArray<UIView *> *targetViews;
+@property (nonatomic, readonly) NSArray<FHSView *> *views;
+@property (nonatomic          ) NSArray<FHSViewSnapshot *> *snapshots;
+@property (nonatomic,         ) FHSSnapshotView *snapshotView;
+
+@property (nonatomic, readonly) UIView *containerView;
+@property (nonatomic, readonly) NSArray<UIView *> *viewsAtTap;
+@property (nonatomic, readonly) NSMutableSet<Class> *forceHideHeaders;
+@end
+
+@implementation FHSViewController
+@synthesize views = _views;
+@synthesize snapshotView = _snapshotView;
+
+#pragma mark - Initialization
+
++ (instancetype)snapshotWindows:(NSArray<UIWindow *> *)windows {
+    return [[self alloc] initWithViews:windows viewsAtTap:nil selectedView:nil];
+}
+
++ (instancetype)snapshotView:(UIView *)view {
+    return [[self alloc] initWithViews:@[view] viewsAtTap:nil selectedView:nil];
+}
+
++ (instancetype)snapshotViewsAtTap:(NSArray<UIView *> *)viewsAtTap selectedView:(UIView *)view {
+    NSParameterAssert(viewsAtTap.count);
+    NSParameterAssert(view.window);
+    return [[self alloc] initWithViews:@[view.window] viewsAtTap:viewsAtTap selectedView:view];
+}
+
+- (id)initWithViews:(NSArray<UIView *> *)views
+         viewsAtTap:(NSArray<UIView *> *)viewsAtTap
+       selectedView:(UIView *)view {
+    NSParameterAssert(views.count);
+
+    self = [super init];
+    if (self) {
+        _forceHideHeaders = [NSMutableSet setWithObject:NSClassFromString(@"_UITableViewCellSeparatorView")];
+        _selectedView = view;
+        _viewsAtTap = viewsAtTap;
+
+        if (!viewsAtTap && kFHSViewControllerExcludeFLEXWindows) {
+            Class flexwindow = [FLEXWindow class];
+            views = [views flex_filtered:^BOOL(UIView *view, NSUInteger idx) {
+                return [view class] != flexwindow;
+            }];
+        }
+
+        _targetViews = views;
+        _views = [views flex_mapped:^id(UIView *view, NSUInteger idx) {
+            BOOL isScrollView = [view.superview isKindOfClass:[UIScrollView class]];
+            return [FHSView forView:view isInScrollView:isScrollView];
+        }];
+    }
+
+    return self;
+}
+
+- (void)refreshSnapshotView {
+    // Alert view to block interaction while we load everything
+    UIAlertController *loading = [FLEXAlert makeAlert:^(FLEXAlert *make) {
+        make.title(@"Please Wait").message(@"Generating snapshot…");
+    }];
+    [self presentViewController:loading animated:YES completion:^{
+        self.snapshots = [self.views flex_mapped:^id(FHSView *view, NSUInteger idx) {
+            return [FHSViewSnapshot snapshotWithView:view];
+        }];
+        FHSSnapshotView *newSnapshotView = [FHSSnapshotView delegate:self];
+
+        // This work is highly intensive so we do it on a background thread first
+        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
+            // Setting the snapshots computes lots of SCNNodes, takes several seconds
+            newSnapshotView.snapshots = self.snapshots;
+
+            // After we finish generating all the model objects and scene nodes, display the view
+            dispatch_async(dispatch_get_main_queue(), ^{
+                // Dismiss alert
+                [loading dismissViewControllerAnimated:YES completion:nil];
+
+                self.snapshotView = newSnapshotView;
+            });
+        });
+    }];
+}
+
+
+#pragma mark - View Controller Lifecycle
+
+- (void)loadView {
+    [super loadView];
+    self.view.backgroundColor = FLEXColor.primaryBackgroundColor;
+}
+
+- (void)viewDidLoad {
+    [super viewDidLoad];
+
+    // Initialize back bar button item for 3D view to look like a button
+    self.navigationItem.hidesBackButton = YES;
+    self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc]
+        initWithTitle:@"2D" style:UIBarButtonItemStylePlain
+        target:self.navigationController
+        action:@selector(toggleHierarchyMode)
+    ];
+}
+
+- (void)viewDidAppear:(BOOL)animated {
+    [super viewDidAppear:animated];
+
+    if (!_snapshotView) {
+        [self refreshSnapshotView];
+    }
+}
+
+
+#pragma mark - Public
+
+- (void)setSelectedView:(UIView *)view {
+    _selectedView = view;
+    self.snapshotView.selectedView = view ? [self snapshotForView:view] : nil;
+}
+
+
+#pragma mark - Private
+
+#pragma mark Properties
+
+- (FHSSnapshotView *)snapshotView {
+    return self.isViewLoaded ? _snapshotView : nil;
+}
+
+- (void)setSnapshotView:(FHSSnapshotView *)snapshotView {
+    NSParameterAssert(snapshotView);
+
+    _snapshotView = snapshotView;
+
+    // Initialize our toolbar items
+    self.toolbarItems = @[
+        [[UIBarButtonItem alloc]
+            initWithCustomView:snapshotView.spacingSlider
+        ],
+        [[UIBarButtonItem alloc]
+            initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace
+            target:nil action:nil
+        ],
+        [[UIBarButtonItem alloc]
+            initWithBarButtonSystemItem:UIBarButtonSystemItemOrganize
+            target:self action:@selector(didPressOptionsButton)
+        ],
+        [[UIBarButtonItem alloc]
+            initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace
+            target:nil action:nil
+        ],
+        [[UIBarButtonItem alloc]
+            initWithCustomView:snapshotView.depthSlider
+        ],
+    ];
+    [self resizeToolbarItems:self.view.frame.size];
+
+    // If we have views-at-tap, dim the other views
+    [snapshotView emphasizeViews:self.viewsAtTap];
+    // Set the selected view, if any
+    snapshotView.selectedView = [self snapshotForView:self.selectedView];
+    snapshotView.headerExclusions = self.forceHideHeaders.allObjects;
+    [snapshotView setNeedsLayout];
+
+    // Remove old snapshot, if any, and add the new one
+    [_snapshotView removeFromSuperview];
+    snapshotView.frame = self.containerView.bounds;
+    [self.containerView addSubview:snapshotView];
+}
+
+- (UIView *)containerView {
+    return self.view;
+}
+
+#pragma mark Helper
+
+- (FHSViewSnapshot *)snapshotForView:(UIView *)view {
+    if (!view) return nil;
+
+    for (FHSViewSnapshot *snapshot in self.snapshots) {
+        FHSViewSnapshot *found = [snapshot snapshotForView:view];
+        if (found) {
+            return found;
+        }
+    }
+
+    @throw NSInternalInconsistencyException;
+    return nil;
+}
+
+#pragma mark Events
+
+- (void)didPressOptionsButton {
+    [FLEXAlert makeSheet:^(FLEXAlert *make) {
+        if (self.selectedView) {
+            make.button(@"Hide selected view").handler(^(NSArray<NSString *> *strings) {
+                [self.snapshotView hideView:[self snapshotForView:self.selectedView]];
+            });
+            make.button(@"Hide headers for views like this").handler(^(NSArray<NSString *> *strings) {
+                Class cls = [self.selectedView class];
+                if (![self.forceHideHeaders containsObject:cls]) {
+                    [self.forceHideHeaders addObject:[self.selectedView class]];
+                    self.snapshotView.headerExclusions = self.forceHideHeaders.allObjects;
+                }
+            });
+        }
+        make.title(@"Options");
+        make.button(@"Toggle headers").handler(^(NSArray<NSString *> *strings) {
+            [self.snapshotView toggleShowHeaders];
+        });
+        make.button(@"Toggle outlines").handler(^(NSArray<NSString *> *strings) {
+            [self.snapshotView toggleShowBorders];
+        });
+        make.button(@"Cancel").cancelStyle();
+    } showFrom:self];
+}
+
+- (void)resizeToolbarItems:(CGSize)viewSize {
+    CGFloat sliderHeights = self.snapshotView.spacingSlider.bounds.size.height;
+    CGFloat sliderWidths = viewSize.width / 3.f;
+    CGRect frame = CGRectMake(0, 0, sliderWidths, sliderHeights);
+    self.snapshotView.spacingSlider.frame = frame;
+    self.snapshotView.depthSlider.frame = frame;
+
+    [self.navigationController.toolbar setNeedsLayout];
+}
+
+- (void)viewWillTransitionToSize:(CGSize)size
+       withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
+    [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
+
+    [coordinator animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext>  _Nonnull context) {
+        [self resizeToolbarItems:self.view.frame.size];
+    } completion:nil];
+}
+
+#pragma mark FHSSnapshotViewDelegate
+
+- (void)didDeselectView:(FHSViewSnapshot *)snapshot {
+    // Our setter would also call the setter for the snapshot view,
+    // which we don't need to do here since it is already selected
+    _selectedView = nil;
+}
+
+- (void)didLongPressView:(FHSViewSnapshot *)snapshot {
+
+}
+
+- (void)didSelectView:(FHSViewSnapshot *)snapshot {
+    // Our setter would also call the setter for the snapshot view,
+    // which we don't need to do here since it is already selected
+    _selectedView = snapshot.view.view;
+}
+
+@end

+ 37 - 0
Classes/ViewHierarchy/SnapshotExplorer/FHSViewSnapshot.h

@@ -0,0 +1,37 @@
+//
+//  FHSViewSnapshot.h
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/9/20.
+//  Copyright © 2020 Flipboard. All rights reserved.
+//
+
+#import "FHSView.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FHSViewSnapshot : NSObject
+
++ (instancetype)snapshotWithView:(FHSView *)view;
+
+@property (nonatomic, readonly) FHSView *view;
+
+@property (nonatomic, readonly) NSString *title;
+/// Whether or not this view item should be visually distinguished
+@property (nonatomic, readwrite) BOOL important;
+
+@property (nonatomic, readonly) CGRect frame;
+@property (nonatomic, readonly) BOOL hidden;
+@property (nonatomic, readonly) UIImage *snapshotImage;
+
+@property (nonatomic, readonly) NSArray<FHSViewSnapshot *> *children;
+@property (nonatomic, readonly) NSString *summary;
+
+/// Returns a different color based on whether or not the view is important
+@property (nonatomic, readonly) UIColor *headerColor;
+
+- (FHSViewSnapshot *)snapshotForView:(UIView *)view;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 62 - 0
Classes/ViewHierarchy/SnapshotExplorer/FHSViewSnapshot.m

@@ -0,0 +1,62 @@
+//
+//  FHSViewSnapshot.m
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/9/20.
+//  Copyright © 2020 Flipboard. All rights reserved.
+//
+
+#import "FHSViewSnapshot.h"
+#import "NSArray+Functional.h"
+
+@implementation FHSViewSnapshot
+
++ (instancetype)snapshotWithView:(FHSView *)view {
+    NSArray *children = [view.children flex_mapped:^id(FHSView *v, NSUInteger idx) {
+        return [self snapshotWithView:v];
+    }];
+    return [[self alloc] initWithView:view children:children];
+}
+
+- (id)initWithView:(FHSView *)view children:(NSArray<FHSViewSnapshot *> *)children {
+    NSParameterAssert(view); NSParameterAssert(children);
+
+    self = [super init];
+    if (self) {
+        _view = view;
+        _title = view.title;
+        _important = view.important;
+        _frame = view.frame;
+        _hidden = view.hidden;
+        _snapshotImage = view.snapshotImage;
+        _children = children;
+        _summary = view.summary;
+    }
+
+    return self;
+}
+
+- (UIColor *)headerColor {
+    if (self.important) {
+        return [UIColor colorWithRed: 0.000 green: 0.533 blue: 1.000 alpha: 0.900];
+    } else {
+        return [UIColor colorWithRed:0.961 green: 0.651 blue: 0.137 alpha: 0.900];
+    }
+}
+
+- (FHSViewSnapshot *)snapshotForView:(UIView *)view {
+    if (view == self.view.view) {
+        return self;
+    }
+
+    for (FHSViewSnapshot *child in self.children) {
+        FHSViewSnapshot *snapshot = [child snapshotForView:view];
+        if (snapshot) {
+            return snapshot;
+        }
+    }
+
+    return nil;
+}
+
+@end

+ 37 - 0
Classes/ViewHierarchy/SnapshotExplorer/Scene/FHSSnapshotNodes.h

@@ -0,0 +1,37 @@
+//
+//  FHSSnapshotNodes.h
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/7/20.
+//
+
+#import "FHSViewSnapshot.h"
+#import <SceneKit/SceneKit.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// Container that holds references to the SceneKit nodes associated with a snapshot.
+@interface FHSSnapshotNodes : NSObject
+
++ (instancetype)snapshot:(FHSViewSnapshot *)snapshot depth:(NSInteger)depth;
+
+@property (nonatomic, readonly) FHSViewSnapshot *snapshotItem;
+@property (nonatomic, readonly) NSInteger depth;
+
+/// The view image itself
+@property (nonatomic, nullable) SCNNode *snapshot;
+/// Goes on top of the snapshot, has rounded top corners
+@property (nonatomic, nullable) SCNNode *header;
+/// The bounding box drawn around the snapshot
+@property (nonatomic, nullable) SCNNode *border;
+
+/// Used to indicate when a view is selected
+@property (nonatomic, getter=isHighlighted) BOOL highlighted;
+/// Used to indicate when a view is de-emphasized
+@property (nonatomic, getter=isDimmed) BOOL dimmed;
+
+@property (nonatomic) BOOL forceHideHeader;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 90 - 0
Classes/ViewHierarchy/SnapshotExplorer/Scene/FHSSnapshotNodes.m

@@ -0,0 +1,90 @@
+//
+//  FHSSnapshotNodes.m
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/7/20.
+//
+
+#import "FHSSnapshotNodes.h"
+#import "SceneKit+Snapshot.h"
+
+@interface FHSSnapshotNodes ()
+@property (nonatomic, nullable) SCNNode *highlight;
+@property (nonatomic, nullable) SCNNode *dimming;
+@end
+@implementation FHSSnapshotNodes
+
++ (instancetype)snapshot:(FHSViewSnapshot *)snapshot depth:(NSInteger)depth {
+    FHSSnapshotNodes *nodes = [self new];
+    nodes->_snapshotItem = snapshot;
+    nodes->_depth = depth;
+    return nodes;
+}
+
+- (void)setHighlighted:(BOOL)highlighted {
+    if (_highlighted != highlighted) {
+        _highlighted = highlighted;
+
+        if (highlighted) {
+            if (!self.highlight) {
+                // Create highlight node
+                self.highlight = [SCNNode
+                    highlight:self.snapshotItem
+                    color:[UIColor.blueColor colorWithAlphaComponent:0.5]
+                ];
+            }
+            // Add add highlight node, remove dimming node if dimmed
+            [self.snapshot addChildNode:self.highlight];
+            if (self.isDimmed) {
+                [self.dimming removeFromParentNode];
+            }
+        } else {
+            // Remove highlight node, add back dimming node if dimmed
+            [self.highlight removeFromParentNode];
+            if (self.isDimmed) {
+                [self.snapshot addChildNode:self.dimming];
+            }
+        }
+    }
+}
+
+- (void)setDimmed:(BOOL)dimmed {
+    if (_dimmed != dimmed) {
+        _dimmed = dimmed;
+
+        if (dimmed) {
+            if (!self.dimming) {
+                // Create dimming node
+                self.dimming = [SCNNode
+                    highlight:self.snapshotItem
+                    color:[UIColor.blackColor colorWithAlphaComponent:0.5]
+                ];
+            }
+            // Add add dimming node if not highlighted
+            if (!self.isHighlighted) {
+                [self.snapshot addChildNode:self.dimming];
+            }
+        } else {
+            // Remove dimming node (if not already highlighted)
+            if (!self.isHighlighted) {
+                [self.dimming removeFromParentNode];
+            }
+        }
+    }
+}
+
+- (void)setForceHideHeader:(BOOL)forceHideHeader {
+    if (_forceHideHeader != forceHideHeader) {
+        _forceHideHeader = forceHideHeader;
+
+        if (self.header.parentNode) {
+            self.header.hidden = YES;
+            [self.header removeFromParentNode];
+        } else {
+            self.header.hidden = NO;
+            [self.snapshot addChildNode:self.header];
+        }
+    }
+}
+
+@end

+ 62 - 0
Classes/ViewHierarchy/SnapshotExplorer/Scene/SceneKit+Snapshot.h

@@ -0,0 +1,62 @@
+//
+//  SceneKit+Snapshot.h
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/8/20.
+//
+
+#import <SceneKit/SceneKit.h>
+#import "FHSViewSnapshot.h"
+@class FHSSnapshotNodes;
+
+extern CGFloat const kFHSSmallZOffset;
+
+#pragma mark SCNNode
+@interface SCNNode (Snapshot)
+
+/// @return the nearest ancestor snapshot node starting at this node
+@property (nonatomic, readonly) SCNNode *nearestAncestorSnapshot;
+
+/// @return a node that renders a highlight overlay over a specified snapshot
++ (instancetype)highlight:(FHSViewSnapshot *)view color:(UIColor *)color;
+/// @return a node that renders a snapshot image
++ (instancetype)snapshot:(FHSViewSnapshot *)view;
+/// @return a node that draws a line between two vertices
++ (instancetype)lineFrom:(SCNVector3)v1 to:(SCNVector3)v2 color:(UIColor *)lineColor;
+
+/// @return a node that can be used to render a colored border around the specified node
+- (instancetype)borderWithColor:(UIColor *)color;
+/// @return a node that renders a header above a snapshot node
+///         using the title text from the view, if specified
++ (instancetype)header:(FHSViewSnapshot *)view;
+
+/// @return a SceneKit node that recursively renders a hierarchy
+///         of UI elements starting at the specified snapshot
++ (instancetype)snapshot:(FHSViewSnapshot *)view
+                  parent:(FHSViewSnapshot *)parentView
+              parentNode:(SCNNode *)parentNode
+                    root:(SCNNode *)rootNode
+                   depth:(NSInteger *)depthOut
+                nodesMap:(NSMutableDictionary<NSString *, FHSSnapshotNodes *> *)nodesMap
+             hideHeaders:(BOOL)hideHeaders;
+
+@end
+
+
+#pragma mark SCNShape
+@interface SCNShape (Snapshot)
+/// @return a shape with the given path, 0 extrusion depth, and a double-sided
+///         material with the given diffuse contents inserted at index 0
++ (instancetype)shapeWithPath:(UIBezierPath *)path materialDiffuse:(id)contents;
+/// @return a shape that is used to render the background of the snapshot header
++ (instancetype)nameHeader:(UIColor *)color frame:(CGRect)frame corners:(CGFloat)cornerRadius;
+
+@end
+
+
+#pragma mark SCNText
+@interface SCNText (Snapshot)
+/// @return text geometry used to render text inside the snapshot header
++ (instancetype)labelGeometry:(NSString *)text font:(UIFont *)font;
+
+@end

+ 278 - 0
Classes/ViewHierarchy/SnapshotExplorer/Scene/SceneKit+Snapshot.m

@@ -0,0 +1,278 @@
+//
+//  SceneKit+Snapshot.m
+//  FLEX
+//
+//  Created by Tanner Bennett on 1/8/20.
+//
+
+#import "SceneKit+Snapshot.h"
+#import "FHSSnapshotNodes.h"
+
+/// This value is chosen such that this offset can be applied to avoid
+/// z-fighting amongst nodes at the same z-position, but small enough
+/// that they appear to visually be on the same plane.
+CGFloat const kFHSSmallZOffset = 0.05;
+CGFloat const kHeaderVerticalInset = 8.0;
+
+#pragma mark SCNGeometry
+@interface SCNGeometry (SnapshotPrivate)
+@end
+@implementation SCNGeometry (SnapshotPrivate)
+
+- (void)addDoubleSidedMaterialWithDiffuseContents:(id)contents {
+    SCNMaterial *material = [SCNMaterial new];
+    material.doubleSided = YES;
+    material.diffuse.contents = contents;
+    [self insertMaterial:material atIndex:0];
+}
+
+@end
+
+#pragma mark SCNNode
+@implementation SCNNode (Snapshot)
+
+- (SCNNode *)nearestAncestorSnapshot {
+    SCNNode *node = self;
+
+    while (!node.name && node) {
+        node = node.parentNode;
+    }
+
+    return node;
+}
+
++ (instancetype)shapeNodeWithSize:(CGSize)size materialDiffuse:(id)contents offsetZ:(BOOL)offsetZ {
+    UIBezierPath *path = [UIBezierPath bezierPathWithRect:CGRectMake(
+        0, 0, size.width, size.height
+    )];
+    SCNShape *shape = [SCNShape shapeWithPath:path materialDiffuse:contents];
+    SCNNode *node = [SCNNode nodeWithGeometry:shape];
+    
+    if (offsetZ) {
+        node.position = SCNVector3Make(0, 0, kFHSSmallZOffset);
+    }
+    return node;
+}
+
++ (instancetype)highlight:(FHSViewSnapshot *)view color:(UIColor *)color {
+    return [self shapeNodeWithSize:view.frame.size materialDiffuse:color offsetZ:YES];
+}
+
++ (instancetype)snapshot:(FHSViewSnapshot *)view {
+    id image = view.snapshotImage;
+    return [self shapeNodeWithSize:view.frame.size materialDiffuse:image offsetZ:NO];
+}
+
++ (instancetype)lineFrom:(SCNVector3)v1 to:(SCNVector3)v2 color:(UIColor *)lineColor {
+    SCNVector3 vertices[2] = { v1, v2 };
+    int32_t _indices[2] = { 0, 1 };
+    NSData *indices = [NSData dataWithBytes:_indices length:sizeof(_indices)];
+    
+    SCNGeometrySource *source = [SCNGeometrySource geometrySourceWithVertices:vertices count:2];
+    SCNGeometryElement *element = [SCNGeometryElement
+        geometryElementWithData:indices
+        primitiveType:SCNGeometryPrimitiveTypeLine
+        primitiveCount:2
+        bytesPerIndex:sizeof(int32_t)
+    ];
+
+    SCNGeometry *geometry = [SCNGeometry geometryWithSources:@[source] elements:@[element]];
+    [geometry addDoubleSidedMaterialWithDiffuseContents:lineColor];
+    return [SCNNode nodeWithGeometry:geometry];
+}
+
+- (instancetype)borderWithColor:(UIColor *)color {
+    struct { SCNVector3 min, max; } bb;
+    [self getBoundingBoxMin:&bb.min max:&bb.max];
+
+    SCNVector3 topLeft = SCNVector3Make(bb.min.x, bb.max.y, kFHSSmallZOffset);
+    SCNVector3 bottomLeft = SCNVector3Make(bb.min.x, bb.min.y, kFHSSmallZOffset);
+    SCNVector3 topRight = SCNVector3Make(bb.max.x, bb.max.y, kFHSSmallZOffset);
+    SCNVector3 bottomRight = SCNVector3Make(bb.max.x, bb.min.y, kFHSSmallZOffset);
+
+    SCNNode *top = [SCNNode lineFrom:topLeft to:topRight color:color];
+    SCNNode *left = [SCNNode lineFrom:bottomLeft to:topLeft color:color];
+    SCNNode *bottom = [SCNNode lineFrom:bottomLeft to:bottomRight color:color];
+    SCNNode *right = [SCNNode lineFrom:bottomRight to:topRight color:color];
+
+    SCNNode *border = [SCNNode new];
+    [border addChildNode:top];
+    [border addChildNode:left];
+    [border addChildNode:bottom];
+    [border addChildNode:right];
+
+    return border;
+}
+
++ (instancetype)header:(FHSViewSnapshot *)view {
+    SCNText *text = [SCNText labelGeometry:view.title font:[UIFont boldSystemFontOfSize:13.0]];
+    SCNNode *textNode = [SCNNode nodeWithGeometry:text];
+
+    struct { SCNVector3 min, max; } bb;
+    [textNode getBoundingBoxMin:&bb.min max:&bb.max];
+    CGFloat textWidth = bb.max.x - bb.min.x;
+    CGFloat textHeight = bb.max.y - bb.min.y;
+
+    CGFloat snapshotWidth = view.frame.size.width;
+    CGFloat headerWidth = MAX(snapshotWidth, textWidth);
+    CGRect frame = CGRectMake(0, 0, headerWidth, textHeight + (kHeaderVerticalInset * 2));
+    SCNNode *headerNode = [SCNNode nodeWithGeometry:[SCNShape
+        nameHeader:view.headerColor frame:frame corners:8
+    ]];
+    [headerNode addChildNode:textNode];
+
+    textNode.position = SCNVector3Make(
+        (frame.size.width / 2.f) - (textWidth / 2.f),
+        (frame.size.height / 2.f) - (textHeight / 2.f),
+        kFHSSmallZOffset
+    );
+    headerNode.position = SCNVector3Make(
+       (snapshotWidth / 2.f) - (headerWidth / 2.f),
+       view.frame.size.height,
+       kFHSSmallZOffset
+    );
+
+    return headerNode;
+}
+
++ (instancetype)snapshot:(FHSViewSnapshot *)view
+                  parent:(FHSViewSnapshot *)parent
+              parentNode:(SCNNode *)parentNode
+                    root:(SCNNode *)rootNode
+                   depth:(NSInteger *)depthOut
+                nodesMap:(NSMutableDictionary<NSString *, FHSSnapshotNodes *> *)nodesMap
+             hideHeaders:(BOOL)hideHeaders {
+    NSInteger const depth = *depthOut;
+
+    // Ignore elements that are not visible.
+    // These should appear in the list, but not in the 3D view.
+    if (view.hidden || CGSizeEqualToSize(view.frame.size, CGSizeZero)) {
+        return nil;
+    }
+
+    // Create a node whose contents are the snapshot of the element
+    SCNNode *node = [self snapshot:view];
+    node.name = view.view.identifier;
+
+    // Begin building node tree
+    FHSSnapshotNodes *nodes = [FHSSnapshotNodes snapshot:view depth:depth];
+    nodes.snapshot = node;
+
+    // The node must be added to the root node
+    // for the coordinate space calculations below to work
+    [rootNode addChildNode:node];
+    node.position = ({
+        // Flip the y-coordinate since SceneKit has a
+        // flipped version of the UIKit coordinate system
+        CGRect pframe = parent ? parent.frame : CGRectZero;
+        CGFloat y = parent ? pframe.size.height - CGRectGetMaxY(view.frame) : 0;
+
+        // To simplify calculating the z-axis spacing between the layers, we make
+        // each snapshot node a direct child of the root rather than embedding
+        // the nodes in their parent nodes in the same structure as the UI elements
+        // themselves. With this flattened hierarchy, the z-position can be
+        // calculated for every node simply by multiplying the spacing by the depth.
+        //
+        // `parentSnapshotNode` as referenced here is NOT the actual parent node
+        // of `node`, it is the node corresponding to the parent of the UI element.
+        // It is used to convert from frame coordinates, which are relative to
+        // the bounds of the parent, to coordinates relative to the root node.
+        SCNVector3 positionRelativeToParent = SCNVector3Make(view.frame.origin.x, y, 0);
+        SCNVector3 positionRelativeToRoot;
+        if (parent) {
+            positionRelativeToRoot = [rootNode convertPosition:positionRelativeToParent fromNode:parentNode];
+        } else {
+            positionRelativeToRoot = positionRelativeToParent;
+        }
+        positionRelativeToRoot.z = 50 * depth;
+        positionRelativeToRoot;
+    });
+
+    // Make border node
+    nodes.border = [node borderWithColor:view.headerColor];
+    [node addChildNode:nodes.border];
+
+    // Make header node
+    nodes.header = [SCNNode header:view];
+    [node addChildNode:nodes.header];
+    if (hideHeaders) {
+        nodes.header.hidden = YES;
+    }
+
+    nodesMap[view.view.identifier] = nodes;
+
+    NSMutableArray<FHSViewSnapshot *> *checkForIntersect = [NSMutableArray new];
+    NSInteger maxChildDepth = depth;
+
+    // Recurse to child nodes; overlapping children have higher depths
+    for (FHSViewSnapshot *child in view.children) {
+        NSInteger childDepth = depth + 1;
+
+        // Children that intersect a sibling are rendered
+        // in a separate layer above the previous siblings
+        for (FHSViewSnapshot *sibling in checkForIntersect) {
+            if (CGRectIntersectsRect(sibling.frame, child.frame)) {
+                childDepth = maxChildDepth + 1;
+                break;
+            }
+        }
+
+        id didMakeNode = [SCNNode
+            snapshot:child
+            parent:view
+            parentNode:node
+            root:rootNode
+            depth:&childDepth
+            nodesMap:nodesMap
+            hideHeaders:hideHeaders
+        ];
+        if (didMakeNode) {
+            maxChildDepth = MAX(childDepth, maxChildDepth);
+            [checkForIntersect addObject:child];
+        }
+    }
+
+    *depthOut = maxChildDepth;
+    return node;
+}
+
+@end
+
+
+#pragma mark SCNShape
+@implementation SCNShape (Snapshot)
+
++ (instancetype)shapeWithPath:(UIBezierPath *)path materialDiffuse:(id)contents {
+    SCNShape *shape = [SCNShape shapeWithPath:path extrusionDepth:0];
+    [shape addDoubleSidedMaterialWithDiffuseContents:contents];
+    return shape;
+}
+
++ (instancetype)nameHeader:(UIColor *)color frame:(CGRect)frame corners:(CGFloat)radius {
+    UIBezierPath *path = [UIBezierPath
+        bezierPathWithRoundedRect:frame
+        byRoundingCorners:UIRectCornerBottomLeft | UIRectCornerBottomRight
+        cornerRadii:CGSizeMake(radius, radius)
+    ];
+    return [SCNShape shapeWithPath:path materialDiffuse:color];
+}
+
+@end
+
+
+#pragma mark SCNText
+@implementation SCNText (Snapshot)
+
++ (instancetype)labelGeometry:(NSString *)text font:(UIFont *)font {
+    NSParameterAssert(text);
+
+    SCNText *label = [self new];
+    label.string = text;
+    label.font = font;
+    label.alignmentMode = kCAAlignmentCenter;
+    label.truncationMode = kCATruncationEnd;
+
+    return label;
+}
+
+@end

Classes/ViewHierarchy/FLEXHierarchyTableViewCell.h → Classes/ViewHierarchy/TreeExplorer/FLEXHierarchyTableViewCell.h


Classes/ViewHierarchy/FLEXHierarchyTableViewCell.m → Classes/ViewHierarchy/TreeExplorer/FLEXHierarchyTableViewCell.m


+ 20 - 0
Classes/ViewHierarchy/TreeExplorer/FLEXHierarchyTableViewController.h

@@ -0,0 +1,20 @@
+//
+//  FLEXHierarchyTableViewController.h
+//  Flipboard
+//
+//  Created by Ryan Olson on 2014-05-01.
+//  Copyright (c) 2014 Flipboard. All rights reserved.
+//
+
+#import "FLEXTableViewController.h"
+
+@interface FLEXHierarchyTableViewController : FLEXTableViewController
+
++ (instancetype)windows:(NSArray<UIWindow *> *)allWindows
+             viewsAtTap:(NSArray<UIView *> *)viewsAtTap
+           selectedView:(UIView *)selectedView;
+
+@property (nonatomic) UIView *selectedView;
+@property (nonatomic) void(^didSelectRowAction)(UIView *selectedView);
+
+@end

+ 117 - 72
Classes/ViewHierarchy/FLEXHierarchyTableViewController.m

@@ -13,24 +13,42 @@
 #import "FLEXObjectExplorerViewController.h"
 #import "FLEXObjectExplorerFactory.h"
 #import "FLEXResources.h"
+#import "FLEXWindow.h"
 
-static const NSInteger kFLEXHierarchyScopeViewsAtTapIndex = 0;
-static const NSInteger kFLEXHierarchyScopeFullHierarchyIndex = 1;
+typedef NS_ENUM(NSUInteger, FLEXHierarchyScope) {
+    FLEXHierarchyScopeFullHierarchy,
+    FLEXHierarchyScopeViewsAtTap
+};
 
 @interface FLEXHierarchyTableViewController ()
 
 @property (nonatomic) NSArray<UIView *> *allViews;
-@property (nonatomic) NSDictionary<NSValue *, NSNumber *> *depthsForViews;
+@property (nonatomic) NSMapTable<UIView *, NSNumber *> *depthsForViews;
 @property (nonatomic) NSArray<UIView *> *viewsAtTap;
-@property (nonatomic) UIView *selectedView;
 @property (nonatomic) NSArray<UIView *> *displayedViews;
+@property (nonatomic, readonly) BOOL showScopeBar;
 
 @end
 
 @implementation FLEXHierarchyTableViewController
 
-- (instancetype)initWithViews:(NSArray<UIView *> *)allViews viewsAtTap:(NSArray<UIView *> *)viewsAtTap selectedView:(UIView *)selectedView depths:(NSDictionary<NSValue *, NSNumber *> *)depthsForViews
-{
++ (instancetype)windows:(NSArray<UIWindow *> *)allWindows
+             viewsAtTap:(NSArray<UIView *> *)viewsAtTap
+           selectedView:(UIView *)selected {
+    NSParameterAssert(allWindows.count);
+
+    NSArray *allViews = [self allViewsInHierarchy:allWindows];
+    NSMapTable *depths = [self hierarchyDepthsForViews:allViews];
+    return [[self alloc] initWithViews:allViews viewsAtTap:viewsAtTap selectedView:selected depths:depths];
+}
+
+- (instancetype)initWithViews:(NSArray<UIView *> *)allViews
+                   viewsAtTap:(NSArray<UIView *> *)viewsAtTap
+                 selectedView:(UIView *)selectedView
+                       depths:(NSMapTable<UIView *, NSNumber *> *)depthsForViews {
+    NSParameterAssert(allViews);
+    NSParameterAssert(depthsForViews.count == allViews.count);
+
     self = [super initWithStyle:UITableViewStylePlain];
     if (self) {
         self.allViews = allViews;
@@ -38,31 +56,29 @@ static const NSInteger kFLEXHierarchyScopeFullHierarchyIndex = 1;
         self.viewsAtTap = viewsAtTap;
         self.selectedView = selectedView;
         
-        self.title = @"View Hierarchy";
+        self.title = @"View Hierarchy Tree";
     }
+
     return self;
 }
 
-- (void)viewDidLoad
-{
+- (void)viewDidLoad {
     [super viewDidLoad];
 
-    // Preserve selection between presentations.
+    // Preserve selection between presentations
     self.clearsSelectionOnViewWillAppear = NO;
-    // Done button.
-    self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(donePressed:)];
     
-    // A little more breathing room.
+    // A little more breathing room
     self.tableView.rowHeight = 50.0;
     self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
-    // Separator inset clashes with persistent cell selection.
+    // Separator inset clashes with persistent cell selection
     [self.tableView setSeparatorInset:UIEdgeInsetsZero];
     
     self.showsSearchBar = YES;
     self.pinSearchBar = YES;
     self.searchBarDebounceInterval = kFLEXDebounceInstant;
     self.automaticallyShowsSearchBarCancelButton = NO;
-    if ([self showScopeBar]) {
+    if (self.showScopeBar) {
         self.searchController.searchBar.showsScopeBar = YES;
         self.searchController.searchBar.scopeButtonTitles = @[@"Views at Tap", @"Full Hierarchy"];
     }
@@ -70,32 +86,66 @@ static const NSInteger kFLEXHierarchyScopeFullHierarchyIndex = 1;
     [self updateDisplayedViews];
 }
 
-- (void)viewDidAppear:(BOOL)animated
-{
+- (void)viewDidAppear:(BOOL)animated {
     [super viewDidAppear:animated];
-    
-    [self trySelectCellForSelectedViewWithScrollPosition:UITableViewScrollPositionMiddle];
+    [self trySelectCellForSelectedView];
+}
+
+
+#pragma mark - Hierarchy helpers
+
++ (NSArray<UIView *> *)allViewsInHierarchy:(NSArray<UIWindow *> *)windows {
+    return [windows flex_flatmapped:^id(UIWindow *window, NSUInteger idx) {
+        if (![window isKindOfClass:[FLEXWindow class]]) {
+            return [self viewWithRecursiveSubviews:window];
+        }
+
+        return nil;
+    }];
+}
+
++ (NSArray<UIView *> *)viewWithRecursiveSubviews:(UIView *)view {
+    NSMutableArray<UIView *> *subviews = [NSMutableArray arrayWithObject:view];
+    for (UIView *subview in view.subviews) {
+        [subviews addObjectsFromArray:[self viewWithRecursiveSubviews:subview]];
+    }
+
+    return subviews;
+}
+
++ (NSMapTable<UIView *, NSNumber *> *)hierarchyDepthsForViews:(NSArray<UIView *> *)views {
+    NSMapTable<UIView *, NSNumber *> *depths = [NSMapTable strongToStrongObjectsMapTable];
+    for (UIView *view in views) {
+        NSInteger depth = 0;
+        UIView *tryView = view;
+        while (tryView.superview) {
+            tryView = tryView.superview;
+            depth++;
+        }
+        depths[(id)view] = @(depth);
+    }
+
+    return depths;
 }
 
 
 #pragma mark Selection and Filtering Helpers
 
-- (void)trySelectCellForSelectedViewWithScrollPosition:(UITableViewScrollPosition)scrollPosition
-{
+- (void)trySelectCellForSelectedView {
     NSUInteger selectedViewIndex = [self.displayedViews indexOfObject:self.selectedView];
     if (selectedViewIndex != NSNotFound) {
+        UITableViewScrollPosition scrollPosition = UITableViewScrollPositionMiddle;
         NSIndexPath *selectedViewIndexPath = [NSIndexPath indexPathForRow:selectedViewIndex inSection:0];
         [self.tableView selectRowAtIndexPath:selectedViewIndexPath animated:YES scrollPosition:scrollPosition];
     }
 }
 
-- (void)updateDisplayedViews
-{
+- (void)updateDisplayedViews {
     NSArray<UIView *> *candidateViews = nil;
-    if ([self showScopeBar]) {
-        if (self.selectedScope == kFLEXHierarchyScopeViewsAtTapIndex) {
+    if (self.showScopeBar) {
+        if (self.selectedScope == FLEXHierarchyScopeViewsAtTap) {
             candidateViews = self.viewsAtTap;
-        } else if (self.selectedScope == kFLEXHierarchyScopeFullHierarchyIndex) {
+        } else if (self.selectedScope == FLEXHierarchyScopeFullHierarchy) {
             candidateViews = self.allViews;
         }
     } else {
@@ -117,37 +167,38 @@ static const NSInteger kFLEXHierarchyScopeFullHierarchyIndex = 1;
     [self.tableView reloadData];
 }
 
-#pragma mark - Search Bar
+- (void)setSelectedView:(UIView *)selectedView {
+    _selectedView = selectedView;
+    if (self.isViewLoaded) {
+        [self trySelectCellForSelectedView];
+    }
+}
 
-- (BOOL)showScopeBar
-{
+
+#pragma mark - Search Bar / Scope Bar
+
+- (BOOL)showScopeBar {
     return self.viewsAtTap.count > 0;
 }
 
-- (void)updateSearchResults:(NSString *)newText
-{
+- (void)updateSearchResults:(NSString *)newText {
     [self updateDisplayedViews];
     
-    // If the search bar text field is active, don't scroll on selection because we may want to continue typing.
-    // Otherwise, scroll so that the selected cell is visible.
-    UITableViewScrollPosition scrollPosition = self.searchController.searchBar.isFirstResponder ? UITableViewScrollPositionNone : UITableViewScrollPositionMiddle;
-    [self trySelectCellForSelectedViewWithScrollPosition:scrollPosition];
+    // If the search bar text field is active, don't scroll on selection because we may want
+    // to continue typing. Otherwise, scroll so that the selected cell is visible.
+    if (self.searchController.searchBar.isFirstResponder) {
+        [self trySelectCellForSelectedView];
+    }
 }
 
-#pragma mark - Table View Data Source
 
-- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
-{
-    return 1;
-}
+#pragma mark - Table View Data Source
 
-- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
-{
+- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
     return self.displayedViews.count;
 }
 
-- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
-{
+- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
     static NSString *CellIdentifier = @"Cell";
     FLEXHierarchyTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
     if (!cell) {
@@ -155,55 +206,49 @@ static const NSInteger kFLEXHierarchyScopeFullHierarchyIndex = 1;
     }
     
     UIView *view = self.displayedViews[indexPath.row];
-    NSNumber *depth = [self.depthsForViews objectForKey:[NSValue valueWithNonretainedObject:view]];
-    UIColor *viewColor = [FLEXUtility consistentRandomColorForObject:view];
+
     cell.textLabel.text = [FLEXUtility descriptionForView:view includingFrame:NO];
     cell.detailTextLabel.text = [FLEXUtility detailDescriptionForView:view];
-    cell.viewColor = viewColor;
-    cell.viewDepth = [depth integerValue];
+    cell.viewColor = [FLEXUtility consistentRandomColorForObject:view];
+    cell.viewDepth = self.depthsForViews[view].integerValue;
+
     if (view.isHidden || view.alpha < 0.01) {
-        cell.textLabel.textColor = [FLEXColor deemphasizedTextColor];
-        cell.detailTextLabel.textColor = [FLEXColor deemphasizedTextColor];
+        cell.textLabel.textColor = FLEXColor.deemphasizedTextColor;
+        cell.detailTextLabel.textColor = FLEXColor.deemphasizedTextColor;
     } else {
-        cell.textLabel.textColor = [FLEXColor primaryTextColor];
-        cell.detailTextLabel.textColor = [FLEXColor primaryTextColor];
+        cell.textLabel.textColor = FLEXColor.primaryTextColor;
+        cell.detailTextLabel.textColor = FLEXColor.primaryTextColor;
     }
     
-    // Use a pattern-based colour to simplify application of the checker pattern.
-    static UIColor *checkerPatternColour = nil;
+    // Use a pattern-based color to simplify application of the checker pattern.
+    static UIColor *checkerPatternColor = nil;
     static dispatch_once_t once;
     dispatch_once(&once, ^{
-        checkerPatternColour = [UIColor colorWithPatternImage:[FLEXResources checkerPattern]];
+        checkerPatternColor = [UIColor colorWithPatternImage:FLEXResources.checkerPattern];
     });
     
-    UIColor *viewColour = view.backgroundColor;
-    if (!viewColour || [viewColour isEqual:UIColor.clearColor]) {
-        cell.viewBackgroundColorView.backgroundColor = checkerPatternColour;
+    UIColor *viewColor = view.backgroundColor;
+    if (!viewColor || [viewColor isEqual:UIColor.clearColor]) {
+        cell.viewBackgroundColorView.backgroundColor = checkerPatternColor;
     } else {
-        cell.viewBackgroundColorView.backgroundColor = viewColour;
+        cell.viewBackgroundColorView.backgroundColor = viewColor;
     }
     
     return cell;
 }
 
-- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
-{
-    self.selectedView = self.displayedViews[indexPath.row];
-    [self.delegate hierarchyViewController:self didFinishWithSelectedView:self.selectedView];
+- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
+    _selectedView = self.displayedViews[indexPath.row]; // Don't scroll, avoid setter
+    #warning does this get called when we programmatically select a view?
+    if (self.didSelectRowAction) {
+        self.didSelectRowAction(_selectedView);
+    }
 }
 
-- (void)tableView:(UITableView *)tableView accessoryButtonTappedForRowWithIndexPath:(NSIndexPath *)indexPath
-{
+- (void)tableView:(UITableView *)tableView accessoryButtonTappedForRowWithIndexPath:(NSIndexPath *)indexPath {
     UIView *drillInView = self.displayedViews[indexPath.row];
     FLEXObjectExplorerViewController *viewExplorer = [FLEXObjectExplorerFactory explorerViewControllerForObject:drillInView];
     [self.navigationController pushViewController:viewExplorer animated:YES];
 }
 
-#pragma mark - Button Actions
-
-- (void)donePressed:(id)sender
-{
-    [self.delegate hierarchyViewController:self didFinishWithSelectedView:self.selectedView];
-}
-
 @end

+ 107 - 11
FLEX.xcodeproj/project.pbxproj

@@ -18,19 +18,19 @@
 		2EF6B04D1D494BE50006BDA5 /* FLEXNetworkCurlLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = 2EF6B04B1D494BE50006BDA5 /* FLEXNetworkCurlLogger.m */; };
 		2EF6B04E1D494BE50006BDA5 /* FLEXNetworkCurlLogger.h in Headers */ = {isa = PBXBuildFile; fileRef = 2EF6B04C1D494BE50006BDA5 /* FLEXNetworkCurlLogger.h */; };
 		3A4C94251B5B20570088C3F2 /* FLEX.h in Headers */ = {isa = PBXBuildFile; fileRef = 3A4C94241B5B20570088C3F2 /* FLEX.h */; settings = {ATTRIBUTES = (Public, ); }; };
-		3A4C94CD1B5B21410088C3F2 /* FLEXGlobalsEntry.h in Headers */ = {isa = PBXBuildFile; fileRef = 3A4C94441B5B21410088C3F2 /* FLEXGlobalsEntry.h */; settings = {ATTRIBUTES = (Private, ); }; };
+		3A4C94CD1B5B21410088C3F2 /* FLEXGlobalsEntry.h in Headers */ = {isa = PBXBuildFile; fileRef = 3A4C94441B5B21410088C3F2 /* FLEXGlobalsEntry.h */; };
 		3A4C94CE1B5B21410088C3F2 /* FLEXGlobalsEntry.m in Sources */ = {isa = PBXBuildFile; fileRef = 3A4C94451B5B21410088C3F2 /* FLEXGlobalsEntry.m */; };
 		3A4C94D31B5B21410088C3F2 /* FLEXObjectExplorerFactory.h in Headers */ = {isa = PBXBuildFile; fileRef = 3A4C944A1B5B21410088C3F2 /* FLEXObjectExplorerFactory.h */; };
 		3A4C94D41B5B21410088C3F2 /* FLEXObjectExplorerFactory.m in Sources */ = {isa = PBXBuildFile; fileRef = 3A4C944B1B5B21410088C3F2 /* FLEXObjectExplorerFactory.m */; };
 		3A4C94D51B5B21410088C3F2 /* FLEXObjectExplorerViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = 3A4C944C1B5B21410088C3F2 /* FLEXObjectExplorerViewController.h */; settings = {ATTRIBUTES = (Private, ); }; };
 		3A4C94D61B5B21410088C3F2 /* FLEXObjectExplorerViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 3A4C944D1B5B21410088C3F2 /* FLEXObjectExplorerViewController.m */; };
-		3A4C94DD1B5B21410088C3F2 /* FLEXHeapEnumerator.h in Headers */ = {isa = PBXBuildFile; fileRef = 3A4C94551B5B21410088C3F2 /* FLEXHeapEnumerator.h */; settings = {ATTRIBUTES = (Private, ); }; };
+		3A4C94DD1B5B21410088C3F2 /* FLEXHeapEnumerator.h in Headers */ = {isa = PBXBuildFile; fileRef = 3A4C94551B5B21410088C3F2 /* FLEXHeapEnumerator.h */; };
 		3A4C94DE1B5B21410088C3F2 /* FLEXHeapEnumerator.m in Sources */ = {isa = PBXBuildFile; fileRef = 3A4C94561B5B21410088C3F2 /* FLEXHeapEnumerator.m */; };
-		3A4C94E11B5B21410088C3F2 /* FLEXResources.h in Headers */ = {isa = PBXBuildFile; fileRef = 3A4C94591B5B21410088C3F2 /* FLEXResources.h */; settings = {ATTRIBUTES = (Private, ); }; };
+		3A4C94E11B5B21410088C3F2 /* FLEXResources.h in Headers */ = {isa = PBXBuildFile; fileRef = 3A4C94591B5B21410088C3F2 /* FLEXResources.h */; };
 		3A4C94E21B5B21410088C3F2 /* FLEXResources.m in Sources */ = {isa = PBXBuildFile; fileRef = 3A4C945A1B5B21410088C3F2 /* FLEXResources.m */; };
-		3A4C94E31B5B21410088C3F2 /* FLEXRuntimeUtility.h in Headers */ = {isa = PBXBuildFile; fileRef = 3A4C945B1B5B21410088C3F2 /* FLEXRuntimeUtility.h */; settings = {ATTRIBUTES = (Private, ); }; };
+		3A4C94E31B5B21410088C3F2 /* FLEXRuntimeUtility.h in Headers */ = {isa = PBXBuildFile; fileRef = 3A4C945B1B5B21410088C3F2 /* FLEXRuntimeUtility.h */; };
 		3A4C94E41B5B21410088C3F2 /* FLEXRuntimeUtility.m in Sources */ = {isa = PBXBuildFile; fileRef = 3A4C945C1B5B21410088C3F2 /* FLEXRuntimeUtility.m */; };
-		3A4C94E51B5B21410088C3F2 /* FLEXUtility.h in Headers */ = {isa = PBXBuildFile; fileRef = 3A4C945D1B5B21410088C3F2 /* FLEXUtility.h */; settings = {ATTRIBUTES = (Private, ); }; };
+		3A4C94E51B5B21410088C3F2 /* FLEXUtility.h in Headers */ = {isa = PBXBuildFile; fileRef = 3A4C945D1B5B21410088C3F2 /* FLEXUtility.h */; };
 		3A4C94E61B5B21410088C3F2 /* FLEXUtility.m in Sources */ = {isa = PBXBuildFile; fileRef = 3A4C945E1B5B21410088C3F2 /* FLEXUtility.m */; };
 		3A4C94E71B5B21410088C3F2 /* FLEXHierarchyTableViewCell.h in Headers */ = {isa = PBXBuildFile; fileRef = 3A4C94601B5B21410088C3F2 /* FLEXHierarchyTableViewCell.h */; settings = {ATTRIBUTES = (Private, ); }; };
 		3A4C94E81B5B21410088C3F2 /* FLEXHierarchyTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 3A4C94611B5B21410088C3F2 /* FLEXHierarchyTableViewCell.m */; };
@@ -166,6 +166,8 @@
 		C34EE30821CB23CC00BD3A7C /* FLEXOSLogController.h in Headers */ = {isa = PBXBuildFile; fileRef = C34EE30621CB23CC00BD3A7C /* FLEXOSLogController.h */; };
 		C3511B9122D7C99E0057BAB7 /* FLEXTableViewSection.h in Headers */ = {isa = PBXBuildFile; fileRef = C3511B8F22D7C99E0057BAB7 /* FLEXTableViewSection.h */; };
 		C3511B9222D7C99E0057BAB7 /* FLEXTableViewSection.m in Sources */ = {isa = PBXBuildFile; fileRef = C3511B9022D7C99E0057BAB7 /* FLEXTableViewSection.m */; };
+		C362AE8123C7E9D1005A86AE /* NSMapTable+FLEX_Subscripting.h in Headers */ = {isa = PBXBuildFile; fileRef = C362AE7F23C7E9D1005A86AE /* NSMapTable+FLEX_Subscripting.h */; };
+		C362AE8223C7E9D1005A86AE /* NSMapTable+FLEX_Subscripting.m in Sources */ = {isa = PBXBuildFile; fileRef = C362AE8023C7E9D1005A86AE /* NSMapTable+FLEX_Subscripting.m */; };
 		C36FBFCB230F3B98008D95D5 /* FLEXMirror.m in Sources */ = {isa = PBXBuildFile; fileRef = C36FBFB9230F3B97008D95D5 /* FLEXMirror.m */; };
 		C36FBFCC230F3B98008D95D5 /* FLEXProtocolBuilder.h in Headers */ = {isa = PBXBuildFile; fileRef = C36FBFBA230F3B97008D95D5 /* FLEXProtocolBuilder.h */; };
 		C36FBFCD230F3B98008D95D5 /* FLEXMethod.m in Sources */ = {isa = PBXBuildFile; fileRef = C36FBFBB230F3B97008D95D5 /* FLEXMethod.m */; };
@@ -240,6 +242,22 @@
 		C398682923AC370100E9E391 /* FLEXViewShortcuts.h in Headers */ = {isa = PBXBuildFile; fileRef = C3F527C3231891F6009CBA07 /* FLEXViewShortcuts.h */; };
 		C39ED92822D63F3200B5773A /* FLEXAddressExplorerCoordinator.h in Headers */ = {isa = PBXBuildFile; fileRef = C39ED92622D63F3200B5773A /* FLEXAddressExplorerCoordinator.h */; };
 		C39ED92922D63F3200B5773A /* FLEXAddressExplorerCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = C39ED92722D63F3200B5773A /* FLEXAddressExplorerCoordinator.m */; };
+		C3A9422C23C3DA39006871A3 /* FHSView.h in Headers */ = {isa = PBXBuildFile; fileRef = C3A9422823C3DA39006871A3 /* FHSView.h */; };
+		C3A9422D23C3DA39006871A3 /* FHSView.m in Sources */ = {isa = PBXBuildFile; fileRef = C3A9422923C3DA39006871A3 /* FHSView.m */; };
+		C3A9423423C3F98E006871A3 /* FHSViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = C3A9423223C3F98E006871A3 /* FHSViewController.h */; };
+		C3A9423523C3F98E006871A3 /* FHSViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = C3A9423323C3F98E006871A3 /* FHSViewController.m */; };
+		C3A9423823C51B8D006871A3 /* FHSRangeSlider.h in Headers */ = {isa = PBXBuildFile; fileRef = C3A9423623C51B8D006871A3 /* FHSRangeSlider.h */; };
+		C3A9423923C51B8D006871A3 /* FHSRangeSlider.m in Sources */ = {isa = PBXBuildFile; fileRef = C3A9423723C51B8D006871A3 /* FHSRangeSlider.m */; };
+		C3A9423C23C54010006871A3 /* FHSSnapshotView.h in Headers */ = {isa = PBXBuildFile; fileRef = C3A9423A23C54010006871A3 /* FHSSnapshotView.h */; };
+		C3A9423D23C54010006871A3 /* FHSSnapshotView.m in Sources */ = {isa = PBXBuildFile; fileRef = C3A9423B23C54010006871A3 /* FHSSnapshotView.m */; };
+		C3A9424023C5443A006871A3 /* FHSSnapshotNodes.h in Headers */ = {isa = PBXBuildFile; fileRef = C3A9423E23C5443A006871A3 /* FHSSnapshotNodes.h */; };
+		C3A9424123C5443A006871A3 /* FHSSnapshotNodes.m in Sources */ = {isa = PBXBuildFile; fileRef = C3A9423F23C5443A006871A3 /* FHSSnapshotNodes.m */; };
+		C3A9424523C641C1006871A3 /* SceneKit+Snapshot.h in Headers */ = {isa = PBXBuildFile; fileRef = C3A9424323C641C1006871A3 /* SceneKit+Snapshot.h */; };
+		C3A9424623C641C1006871A3 /* SceneKit+Snapshot.m in Sources */ = {isa = PBXBuildFile; fileRef = C3A9424423C641C1006871A3 /* SceneKit+Snapshot.m */; };
+		C3A9424923C78878006871A3 /* FLEXHierarchyViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = C3A9424723C78878006871A3 /* FLEXHierarchyViewController.h */; };
+		C3A9424A23C78878006871A3 /* FLEXHierarchyViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = C3A9424823C78878006871A3 /* FLEXHierarchyViewController.m */; };
+		C3A9424D23C78CFF006871A3 /* FHSViewSnapshot.h in Headers */ = {isa = PBXBuildFile; fileRef = C3A9424B23C78CFF006871A3 /* FHSViewSnapshot.h */; };
+		C3A9424E23C78CFF006871A3 /* FHSViewSnapshot.m in Sources */ = {isa = PBXBuildFile; fileRef = C3A9424C23C78CFF006871A3 /* FHSViewSnapshot.m */; };
 		C3BFD070233C23ED0015FB82 /* NSArray+Functional.h in Headers */ = {isa = PBXBuildFile; fileRef = C3BFD06E233C23ED0015FB82 /* NSArray+Functional.h */; };
 		C3BFD071233C23ED0015FB82 /* NSArray+Functional.m in Sources */ = {isa = PBXBuildFile; fileRef = C3BFD06F233C23ED0015FB82 /* NSArray+Functional.m */; };
 		C3DB9F642107FC9600B46809 /* FLEXObjectRef.h in Headers */ = {isa = PBXBuildFile; fileRef = C3DB9F622107FC9600B46809 /* FLEXObjectRef.h */; };
@@ -456,6 +474,8 @@
 		C34EE30A21CB249E00BD3A7C /* ActivityStreamAPI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ActivityStreamAPI.h; sourceTree = "<group>"; };
 		C3511B8F22D7C99E0057BAB7 /* FLEXTableViewSection.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FLEXTableViewSection.h; sourceTree = "<group>"; };
 		C3511B9022D7C99E0057BAB7 /* FLEXTableViewSection.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FLEXTableViewSection.m; sourceTree = "<group>"; };
+		C362AE7F23C7E9D1005A86AE /* NSMapTable+FLEX_Subscripting.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSMapTable+FLEX_Subscripting.h"; sourceTree = "<group>"; };
+		C362AE8023C7E9D1005A86AE /* NSMapTable+FLEX_Subscripting.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSMapTable+FLEX_Subscripting.m"; sourceTree = "<group>"; };
 		C36FBFB9230F3B97008D95D5 /* FLEXMirror.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLEXMirror.m; sourceTree = "<group>"; };
 		C36FBFBA230F3B97008D95D5 /* FLEXProtocolBuilder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FLEXProtocolBuilder.h; sourceTree = "<group>"; };
 		C36FBFBB230F3B97008D95D5 /* FLEXMethod.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLEXMethod.m; sourceTree = "<group>"; };
@@ -526,6 +546,22 @@
 		C398682423AC359600E9E391 /* FLEXShortcutsFactory+Defaults.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "FLEXShortcutsFactory+Defaults.h"; sourceTree = "<group>"; };
 		C39ED92622D63F3200B5773A /* FLEXAddressExplorerCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FLEXAddressExplorerCoordinator.h; sourceTree = "<group>"; };
 		C39ED92722D63F3200B5773A /* FLEXAddressExplorerCoordinator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FLEXAddressExplorerCoordinator.m; sourceTree = "<group>"; };
+		C3A9422823C3DA39006871A3 /* FHSView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FHSView.h; sourceTree = "<group>"; };
+		C3A9422923C3DA39006871A3 /* FHSView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FHSView.m; sourceTree = "<group>"; };
+		C3A9423223C3F98E006871A3 /* FHSViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FHSViewController.h; sourceTree = "<group>"; };
+		C3A9423323C3F98E006871A3 /* FHSViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FHSViewController.m; sourceTree = "<group>"; };
+		C3A9423623C51B8D006871A3 /* FHSRangeSlider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FHSRangeSlider.h; sourceTree = "<group>"; };
+		C3A9423723C51B8D006871A3 /* FHSRangeSlider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FHSRangeSlider.m; sourceTree = "<group>"; };
+		C3A9423A23C54010006871A3 /* FHSSnapshotView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FHSSnapshotView.h; sourceTree = "<group>"; };
+		C3A9423B23C54010006871A3 /* FHSSnapshotView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FHSSnapshotView.m; sourceTree = "<group>"; };
+		C3A9423E23C5443A006871A3 /* FHSSnapshotNodes.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FHSSnapshotNodes.h; sourceTree = "<group>"; };
+		C3A9423F23C5443A006871A3 /* FHSSnapshotNodes.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FHSSnapshotNodes.m; sourceTree = "<group>"; };
+		C3A9424323C641C1006871A3 /* SceneKit+Snapshot.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SceneKit+Snapshot.h"; sourceTree = "<group>"; };
+		C3A9424423C641C1006871A3 /* SceneKit+Snapshot.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "SceneKit+Snapshot.m"; sourceTree = "<group>"; };
+		C3A9424723C78878006871A3 /* FLEXHierarchyViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FLEXHierarchyViewController.h; sourceTree = "<group>"; };
+		C3A9424823C78878006871A3 /* FLEXHierarchyViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FLEXHierarchyViewController.m; sourceTree = "<group>"; };
+		C3A9424B23C78CFF006871A3 /* FHSViewSnapshot.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FHSViewSnapshot.h; sourceTree = "<group>"; };
+		C3A9424C23C78CFF006871A3 /* FHSViewSnapshot.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FHSViewSnapshot.m; sourceTree = "<group>"; };
 		C3BFD06E233C23ED0015FB82 /* NSArray+Functional.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSArray+Functional.h"; sourceTree = "<group>"; };
 		C3BFD06F233C23ED0015FB82 /* NSArray+Functional.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSArray+Functional.m"; sourceTree = "<group>"; };
 		C3DB9F622107FC9600B46809 /* FLEXObjectRef.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FLEXObjectRef.h; sourceTree = "<group>"; };
@@ -684,10 +720,10 @@
 		3A4C945F1B5B21410088C3F2 /* ViewHierarchy */ = {
 			isa = PBXGroup;
 			children = (
-				3A4C94601B5B21410088C3F2 /* FLEXHierarchyTableViewCell.h */,
-				3A4C94611B5B21410088C3F2 /* FLEXHierarchyTableViewCell.m */,
-				3A4C94621B5B21410088C3F2 /* FLEXHierarchyTableViewController.h */,
-				3A4C94631B5B21410088C3F2 /* FLEXHierarchyTableViewController.m */,
+				C3A9424F23C7ABB2006871A3 /* TreeExplorer */,
+				C3A9422523C3DA1E006871A3 /* SnapshotExplorer */,
+				C3A9424723C78878006871A3 /* FLEXHierarchyViewController.h */,
+				C3A9424823C78878006871A3 /* FLEXHierarchyViewController.m */,
 				3A4C94641B5B21410088C3F2 /* FLEXImagePreviewViewController.h */,
 				3A4C94651B5B21410088C3F2 /* FLEXImagePreviewViewController.m */,
 			);
@@ -982,6 +1018,8 @@
 				C3E5D9FC2316E83700E655DB /* FLEXRuntime+Compare.m */,
 				C34C9BDB23A7F2740031CA3E /* FLEXRuntime+UIKitHelpers.h */,
 				C34C9BDC23A7F2740031CA3E /* FLEXRuntime+UIKitHelpers.m */,
+				C362AE7F23C7E9D1005A86AE /* NSMapTable+FLEX_Subscripting.h */,
+				C362AE8023C7E9D1005A86AE /* NSMapTable+FLEX_Subscripting.m */,
 			);
 			path = Categories;
 			sourceTree = "<group>";
@@ -1036,6 +1074,46 @@
 			path = DataSources;
 			sourceTree = "<group>";
 		};
+		C3A9422523C3DA1E006871A3 /* SnapshotExplorer */ = {
+			isa = PBXGroup;
+			children = (
+				C3A9424223C64190006871A3 /* Scene */,
+				C3A9423223C3F98E006871A3 /* FHSViewController.h */,
+				C3A9423323C3F98E006871A3 /* FHSViewController.m */,
+				C3A9423A23C54010006871A3 /* FHSSnapshotView.h */,
+				C3A9423B23C54010006871A3 /* FHSSnapshotView.m */,
+				C3A9422823C3DA39006871A3 /* FHSView.h */,
+				C3A9422923C3DA39006871A3 /* FHSView.m */,
+				C3A9424B23C78CFF006871A3 /* FHSViewSnapshot.h */,
+				C3A9424C23C78CFF006871A3 /* FHSViewSnapshot.m */,
+				C3A9423623C51B8D006871A3 /* FHSRangeSlider.h */,
+				C3A9423723C51B8D006871A3 /* FHSRangeSlider.m */,
+			);
+			path = SnapshotExplorer;
+			sourceTree = "<group>";
+		};
+		C3A9424223C64190006871A3 /* Scene */ = {
+			isa = PBXGroup;
+			children = (
+				C3A9423E23C5443A006871A3 /* FHSSnapshotNodes.h */,
+				C3A9423F23C5443A006871A3 /* FHSSnapshotNodes.m */,
+				C3A9424323C641C1006871A3 /* SceneKit+Snapshot.h */,
+				C3A9424423C641C1006871A3 /* SceneKit+Snapshot.m */,
+			);
+			path = Scene;
+			sourceTree = "<group>";
+		};
+		C3A9424F23C7ABB2006871A3 /* TreeExplorer */ = {
+			isa = PBXGroup;
+			children = (
+				3A4C94621B5B21410088C3F2 /* FLEXHierarchyTableViewController.h */,
+				3A4C94631B5B21410088C3F2 /* FLEXHierarchyTableViewController.m */,
+				3A4C94601B5B21410088C3F2 /* FLEXHierarchyTableViewCell.h */,
+				3A4C94611B5B21410088C3F2 /* FLEXHierarchyTableViewCell.m */,
+			);
+			path = TreeExplorer;
+			sourceTree = "<group>";
+		};
 		C3D43E9F231D79F60079F6A8 /* Shortcuts */ = {
 			isa = PBXGroup;
 			children = (
@@ -1107,6 +1185,7 @@
 			files = (
 				779B1EDA1C0C4D7C001F5E49 /* FLEXTableListViewController.h in Headers */,
 				3A4C94ED1B5B21410088C3F2 /* FLEXArgumentInputColorView.h in Headers */,
+				C3A9424523C641C1006871A3 /* SceneKit+Snapshot.h in Headers */,
 				3A4C94EB1B5B21410088C3F2 /* FLEXImagePreviewViewController.h in Headers */,
 				C34D4EB823A2B17900C1F903 /* FLEXBundleShortcuts.h in Headers */,
 				C38F3F31230C958F004E3731 /* FLEXAlert.h in Headers */,
@@ -1122,6 +1201,7 @@
 				C3F646C1239EAA8F00D4A011 /* UIPasteboard+FLEX.h in Headers */,
 				94A515141C4CA1C00063292F /* FLEXManager.h in Headers */,
 				C37A0C93218BAC9600848CA7 /* FLEXObjcInternal.h in Headers */,
+				C3A9422C23C3DA39006871A3 /* FHSView.h in Headers */,
 				3A4C953E1B5B21410088C3F2 /* FLEXNetworkTransactionDetailTableViewController.h in Headers */,
 				3A4C95301B5B21410088C3F2 /* FLEXSystemLogMessage.h in Headers */,
 				C398625523AD6C67007E6793 /* FLEXObjcRuntimeViewController.h in Headers */,
@@ -1145,19 +1225,23 @@
 				C398626C23AD71C1007E6793 /* TBRuntimeController.h in Headers */,
 				779B1ED01C0C4D7C001F5E49 /* FLEXMultiColumnTableView.h in Headers */,
 				3A4C94D31B5B21410088C3F2 /* FLEXObjectExplorerFactory.h in Headers */,
+				3A4C94CD1B5B21410088C3F2 /* FLEXGlobalsEntry.h in Headers */,
+				C3A9423423C3F98E006871A3 /* FHSViewController.h in Headers */,
+				3A4C94E31B5B21410088C3F2 /* FLEXRuntimeUtility.h in Headers */,
 				C38DF0EA22CFE4370077B4AD /* FLEXTableViewController.h in Headers */,
 				C36FBFCF230F3B98008D95D5 /* FLEXProtocol.h in Headers */,
 				94A515271C4CA2080063292F /* FLEXToolbarItem.h in Headers */,
-				3A4C94E31B5B21410088C3F2 /* FLEXRuntimeUtility.h in Headers */,
 				3A4C95341B5B21410088C3F2 /* FLEXSystemLogTableViewController.h in Headers */,
 				C34C9BDD23A7F2740031CA3E /* FLEXRuntime+UIKitHelpers.h in Headers */,
 				C398624F23AD6C67007E6793 /* TBKeyPathTokenizer.h in Headers */,
+				C362AE8123C7E9D1005A86AE /* NSMapTable+FLEX_Subscripting.h in Headers */,
 				3A4C95091B5B21410088C3F2 /* FLEXFieldEditorView.h in Headers */,
 				C3E5D9FD2316E83700E655DB /* FLEXRuntime+Compare.h in Headers */,
 				C3490E1F233BDD73002AE200 /* FLEXSingleRowSection.h in Headers */,
 				3A4C95281B5B21410088C3F2 /* FLEXInstancesTableViewController.h in Headers */,
 				3A4C950F1B5B21410088C3F2 /* FLEXMethodCallingViewController.h in Headers */,
 				3A4C94F51B5B21410088C3F2 /* FLEXArgumentInputObjectView.h in Headers */,
+				C3A9424923C78878006871A3 /* FLEXHierarchyViewController.h in Headers */,
 				3A4C94F11B5B21410088C3F2 /* FLEXArgumentInputFontsPickerView.h in Headers */,
 				C3BFD070233C23ED0015FB82 /* NSArray+Functional.h in Headers */,
 				C398682923AC370100E9E391 /* FLEXViewShortcuts.h in Headers */,
@@ -1203,10 +1287,12 @@
 				3A4C953C1B5B21410088C3F2 /* FLEXNetworkTransaction.h in Headers */,
 				C383C3C223B6B429007A321B /* NSTimer+Blocks.h in Headers */,
 				3A4C952E1B5B21410088C3F2 /* FLEXWebViewController.h in Headers */,
+				C3A9424D23C78CFF006871A3 /* FHSViewSnapshot.h in Headers */,
 				C3EE76BF22DFC63600EC0AA0 /* FLEXScopeCarousel.h in Headers */,
 				C398627223AD7951007E6793 /* UIGestureRecognizer+Blocks.h in Headers */,
 				3A4C94E91B5B21410088C3F2 /* FLEXHierarchyTableViewController.h in Headers */,
 				3A4C94F31B5B21410088C3F2 /* FLEXArgumentInputFontView.h in Headers */,
+				C3A9423C23C54010006871A3 /* FHSSnapshotView.h in Headers */,
 				3A4C95261B5B21410088C3F2 /* FLEXGlobalsTableViewController.h in Headers */,
 				C398625923AD6C88007E6793 /* TBToken.h in Headers */,
 				C33C825E2316DC8600DD2451 /* FLEXObjectExplorer.h in Headers */,
@@ -1217,7 +1303,6 @@
 				7349FD6A22B93CDF00051810 /* FLEXColor.h in Headers */,
 				C36FBFD3230F3B98008D95D5 /* FLEXMethod.h in Headers */,
 				C36FBFD8230F3B98008D95D5 /* FLEXPropertyAttributes.h in Headers */,
-				3A4C94CD1B5B21410088C3F2 /* FLEXGlobalsEntry.h in Headers */,
 				3A4C94FB1B5B21410088C3F2 /* FLEXArgumentInputStringView.h in Headers */,
 				C38EF26323A2FCD20047A7EC /* FLEXViewControllerShortcuts.h in Headers */,
 				3A4C95421B5B21410088C3F2 /* FLEXNetworkObserver.h in Headers */,
@@ -1230,11 +1315,13 @@
 				94AAF0381BAF2E1F00DE8760 /* FLEXKeyboardHelpViewController.h in Headers */,
 				71E1C2152307FBB800F5032A /* FLEXKeychainQuery.h in Headers */,
 				C36FBFD6230F3B98008D95D5 /* FLEXIvar.h in Headers */,
+				C3A9423823C51B8D006871A3 /* FHSRangeSlider.h in Headers */,
 				C398626123AD70DF007E6793 /* TBKeyboardToolbar.h in Headers */,
 				94AAF03A1BAF2F0300DE8760 /* FLEXKeyboardShortcutManager.h in Headers */,
 				94A515181C4CA1D70063292F /* FLEXManager+Private.h in Headers */,
 				C383C3B923B6A62A007A321B /* FLEXRuntimeSafety.h in Headers */,
 				C39ED92822D63F3200B5773A /* FLEXAddressExplorerCoordinator.h in Headers */,
+				C3A9424023C5443A006871A3 /* FHSSnapshotNodes.h in Headers */,
 				C32A195E231732E800EB02AC /* FLEXCollectionContentSection.h in Headers */,
 				3A4C94E11B5B21410088C3F2 /* FLEXResources.h in Headers */,
 				779B1ED81C0C4D7C001F5E49 /* FLEXTableLeftCell.h in Headers */,
@@ -1355,6 +1442,7 @@
 				C3878DBC23A749F70038FDBE /* FLEXFieldEditorViewController.m in Sources */,
 				942DCD871BAE0CA300DB5DC2 /* FLEXKeyboardShortcutManager.m in Sources */,
 				C3F977862311B38F0032776D /* NSString+ObjcRuntime.m in Sources */,
+				C3A9424A23C78878006871A3 /* FLEXHierarchyViewController.m in Sources */,
 				C31C4A6A23342A2200C35F12 /* FLEXMetadataSection.m in Sources */,
 				224D49A91C673AB5000EAB86 /* FLEXRealmDatabaseManager.m in Sources */,
 				C39ED92922D63F3200B5773A /* FLEXAddressExplorerCoordinator.m in Sources */,
@@ -1367,6 +1455,7 @@
 				C398682523AC359600E9E391 /* FLEXShortcutsFactory+Defaults.m in Sources */,
 				C3DC287C223ED5F200F48AA6 /* FLEXOSLogController.m in Sources */,
 				3A4C95101B5B21410088C3F2 /* FLEXMethodCallingViewController.m in Sources */,
+				C362AE8223C7E9D1005A86AE /* NSMapTable+FLEX_Subscripting.m in Sources */,
 				C3F646C2239EAA8F00D4A011 /* UIPasteboard+FLEX.m in Sources */,
 				3A4C94F61B5B21410088C3F2 /* FLEXArgumentInputObjectView.m in Sources */,
 				3A4C94EC1B5B21410088C3F2 /* FLEXImagePreviewViewController.m in Sources */,
@@ -1383,16 +1472,19 @@
 				3A4C95081B5B21410088C3F2 /* FLEXDefaultEditorViewController.m in Sources */,
 				779B1ED11C0C4D7C001F5E49 /* FLEXMultiColumnTableView.m in Sources */,
 				C36FBFD0230F3B98008D95D5 /* FLEXProperty.m in Sources */,
+				C3A9424E23C78CFF006871A3 /* FHSViewSnapshot.m in Sources */,
 				C398625623AD6C67007E6793 /* TBKeyPathToolbar.m in Sources */,
 				C3F31D412267D883003C991A /* FLEXMultilineTableViewCell.m in Sources */,
 				3A4C94EE1B5B21410088C3F2 /* FLEXArgumentInputColorView.m in Sources */,
 				C38DF0EB22CFE4370077B4AD /* FLEXTableViewController.m in Sources */,
+				C3A9423D23C54010006871A3 /* FHSSnapshotView.m in Sources */,
 				C387C87B22DFCD6A00750E58 /* FLEXCarouselCell.m in Sources */,
 				3A4C94F01B5B21410088C3F2 /* FLEXArgumentInputDateView.m in Sources */,
 				C398627723AD79B7007E6793 /* NSString+KeyPaths.m in Sources */,
 				C36FBFDC230F3B98008D95D5 /* FLEXIvar.m in Sources */,
 				C3F527C22318670F009CBA07 /* FLEXImageShortcuts.m in Sources */,
 				679F64871BD53B7B00A8C94C /* FLEXCookiesTableViewController.m in Sources */,
+				C3A9422D23C3DA39006871A3 /* FHSView.m in Sources */,
 				3A4C94CE1B5B21410088C3F2 /* FLEXGlobalsEntry.m in Sources */,
 				C398625223AD6C67007E6793 /* TBKeyPathSearchController.m in Sources */,
 				C3E5D9FE2316E83700E655DB /* FLEXRuntime+Compare.m in Sources */,
@@ -1401,6 +1493,7 @@
 				C398627323AD7951007E6793 /* UIGestureRecognizer+Blocks.m in Sources */,
 				C3F646F723A04A7500D4A011 /* FLEXShortcut.m in Sources */,
 				3A4C94FE1B5B21410088C3F2 /* FLEXArgumentInputStructView.m in Sources */,
+				C3A9424123C5443A006871A3 /* FHSSnapshotNodes.m in Sources */,
 				C3F31D402267D883003C991A /* FLEXSubtitleTableViewCell.m in Sources */,
 				C398626D23AD71C1007E6793 /* TBRuntimeController.m in Sources */,
 				C36FBFD9230F3B98008D95D5 /* FLEXProtocolBuilder.m in Sources */,
@@ -1426,6 +1519,7 @@
 				3A4C95311B5B21410088C3F2 /* FLEXSystemLogMessage.m in Sources */,
 				C3F31D422267D883003C991A /* FLEXTableViewCell.m in Sources */,
 				3A4C94F41B5B21410088C3F2 /* FLEXArgumentInputFontView.m in Sources */,
+				C3A9424623C641C1006871A3 /* SceneKit+Snapshot.m in Sources */,
 				3A4C953B1B5B21410088C3F2 /* FLEXNetworkSettingsTableViewController.m in Sources */,
 				3A4C94FC1B5B21410088C3F2 /* FLEXArgumentInputStringView.m in Sources */,
 				3A4C94F81B5B21410088C3F2 /* FLEXArgumentInputNotSupportedView.m in Sources */,
@@ -1433,6 +1527,7 @@
 				C383C3C623B6BB81007A321B /* FLEXCodeFontCell.m in Sources */,
 				3A4C95271B5B21410088C3F2 /* FLEXGlobalsTableViewController.m in Sources */,
 				71E1C2172307FBB800F5032A /* FLEXKeychain.m in Sources */,
+				C3A9423923C51B8D006871A3 /* FHSRangeSlider.m in Sources */,
 				C3F646F323A045DB00D4A011 /* FLEXClassShortcuts.m in Sources */,
 				C398625E23AD6E90007E6793 /* UIFont+FLEX.m in Sources */,
 				C387C88422E0D24A00750E58 /* UIView+FLEX_Layout.m in Sources */,
@@ -1443,6 +1538,7 @@
 				C34D4EB523A2AF2A00C1F903 /* FLEXColorPreviewSection.m in Sources */,
 				3A4C94EA1B5B21410088C3F2 /* FLEXHierarchyTableViewController.m in Sources */,
 				3A4C95331B5B21410088C3F2 /* FLEXSystemLogTableViewCell.m in Sources */,
+				C3A9423523C3F98E006871A3 /* FHSViewController.m in Sources */,
 				C3DB9F652107FC9600B46809 /* FLEXObjectRef.m in Sources */,
 				3A4C95021B5B21410088C3F2 /* FLEXArgumentInputTextView.m in Sources */,
 				C3F977882311B38F0032776D /* NSObject+Reflection.m in Sources */,