added files via upload

This commit is contained in:
Balackburn
2023-06-27 09:54:41 +02:00
commit 2ff6aac218
1420 changed files with 88898 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
//
// FLEXHierarchyViewController.h
// FLEX
//
// Created by Tanner Bennett on 1/9/20.
// Copyright © 2020 FLEX Team. All rights reserved.
//
#import "FLEXNavigationController.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 : FLEXNavigationController
+ (instancetype)delegate:(id<FLEXHierarchyDelegate>)delegate;
+ (instancetype)delegate:(id<FLEXHierarchyDelegate>)delegate
viewsAtTap:(NSArray<UIView *> *)viewsAtTap
selectedView:(UIView *)selectedView;
- (void)toggleHierarchyMode;
@end

View File

@@ -0,0 +1,154 @@
//
// FLEXHierarchyViewController.m
// FLEX
//
// Created by Tanner Bennett on 1/9/20.
// Copyright © 2020 FLEX Team. All rights reserved.
//
#import "FLEXHierarchyViewController.h"
#import "FLEXHierarchyTableViewController.h"
#import "FHSViewController.h"
#import "FLEXUtility.h"
#import "FLEXTabList.h"
#import "FLEXResources.h"
#import "UIBarButtonItem+FLEX.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
flex_itemWithImage:FLEXResources.toggle3DIcon 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: manually added here because the hierarhcy screens need to actually pass
// data back to the explorer view controller so that it can highlight selected views
viewController.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc]
initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(donePressed)
];
[super pushViewController:viewController animated:animated];
}
#pragma mark - Private
- (void)donePressed {
// We need to manually close ourselves here because
// FLEXNavigationController doesn't ever close tabs itself
[FLEXTabList.sharedList closeTab:self];
[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) {
// 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;
self.treeViewController.selectedView = self.selectedView;
break;
case FLEXHierarchyViewMode3DSnapshot:
[self pushViewController:self.snapshotViewController animated:NO];
self.toolbarHidden = NO;
self.snapshotViewController.selectedView = self.selectedView;
break;
}
// Change this last so that self.selectedView works right above
_mode = mode;
}
}
- (UIView *)selectedView {
switch (self.mode) {
case FLEXHierarchyViewModeTree:
return self.treeViewController.selectedView;
case FLEXHierarchyViewMode3DSnapshot:
return self.snapshotViewController.selectedView;
}
}
@end

View File

@@ -0,0 +1,17 @@
//
// FLEXImagePreviewViewController.h
// Flipboard
//
// Created by Ryan Olson on 6/12/14.
// Copyright (c) 2020 FLEX Team. All rights reserved.
//
#import <UIKit/UIKit.h>
@interface FLEXImagePreviewViewController : UIViewController
+ (instancetype)previewForView:(UIView *)view;
+ (instancetype)previewForLayer:(CALayer *)layer;
+ (instancetype)forImage:(UIImage *)image;
@end

View File

@@ -0,0 +1,147 @@
//
// FLEXImagePreviewViewController.m
// Flipboard
//
// Created by Ryan Olson on 6/12/14.
// Copyright (c) 2020 FLEX Team. All rights reserved.
//
#import "FLEXImagePreviewViewController.h"
#import "FLEXUtility.h"
#import "FLEXColor.h"
#import "FLEXResources.h"
@interface FLEXImagePreviewViewController () <UIScrollViewDelegate>
@property (nonatomic) UIImage *image;
@property (nonatomic) UIScrollView *scrollView;
@property (nonatomic) UIImageView *imageView;
@property (nonatomic) UITapGestureRecognizer *bgColorTapGesture;
@property (nonatomic) NSInteger backgroundColorIndex;
@property (nonatomic, readonly) NSArray<UIColor *> *backgroundColors;
@end
#pragma mark -
@implementation FLEXImagePreviewViewController
#pragma mark Initialization
+ (instancetype)previewForView:(UIView *)view {
return [self forImage:[FLEXUtility previewImageForView:view]];
}
+ (instancetype)previewForLayer:(CALayer *)layer {
return [self forImage:[FLEXUtility previewImageForLayer:layer]];
}
+ (instancetype)forImage:(UIImage *)image {
return [[self alloc] initWithImage:image];
}
- (id)initWithImage:(UIImage *)image {
NSParameterAssert(image);
self = [super init];
if (self) {
self.title = @"Preview";
self.image = image;
_backgroundColors = @[FLEXResources.checkerPatternColor, UIColor.whiteColor, UIColor.blackColor];
}
return self;
}
#pragma mark Lifecycle
- (void)viewDidLoad {
[super viewDidLoad];
self.imageView = [[UIImageView alloc] initWithImage:self.image];
self.scrollView = [[UIScrollView alloc] initWithFrame:self.view.bounds];
self.scrollView.delegate = self;
self.scrollView.backgroundColor = self.backgroundColors.firstObject;
self.scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
[self.scrollView addSubview:self.imageView];
self.scrollView.contentSize = self.imageView.frame.size;
self.scrollView.minimumZoomScale = 1.0;
self.scrollView.maximumZoomScale = 2.0;
[self.view addSubview:self.scrollView];
self.bgColorTapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(changeBackground)];
[self.scrollView addGestureRecognizer:self.bgColorTapGesture];
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc]
initWithBarButtonSystemItem:UIBarButtonSystemItemAction
target:self
action:@selector(actionButtonPressed:)
];
}
- (void)viewDidLayoutSubviews {
[self centerContentInScrollViewIfNeeded];
}
#pragma mark UIScrollViewDelegate
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView {
return self.imageView;
}
- (void)scrollViewDidZoom:(UIScrollView *)scrollView {
[self centerContentInScrollViewIfNeeded];
}
#pragma mark Private
- (void)centerContentInScrollViewIfNeeded {
CGFloat horizontalInset = 0.0;
CGFloat verticalInset = 0.0;
if (self.scrollView.contentSize.width < self.scrollView.bounds.size.width) {
horizontalInset = (self.scrollView.bounds.size.width - self.scrollView.contentSize.width) / 2.0;
}
if (self.scrollView.contentSize.height < self.scrollView.bounds.size.height) {
verticalInset = (self.scrollView.bounds.size.height - self.scrollView.contentSize.height) / 2.0;
}
self.scrollView.contentInset = UIEdgeInsetsMake(verticalInset, horizontalInset, verticalInset, horizontalInset);
}
- (void)changeBackground {
self.backgroundColorIndex++;
self.backgroundColorIndex %= self.backgroundColors.count;
self.scrollView.backgroundColor = self.backgroundColors[self.backgroundColorIndex];
}
- (void)actionButtonPressed:(id)sender {
static BOOL canSaveToCameraRoll = NO, didShowWarning = NO;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
if (UIDevice.currentDevice.systemVersion.floatValue < 10) {
canSaveToCameraRoll = YES;
return;
}
NSBundle *mainBundle = NSBundle.mainBundle;
if ([mainBundle.infoDictionary.allKeys containsObject:@"NSPhotoLibraryUsageDescription"]) {
canSaveToCameraRoll = YES;
}
});
UIActivityViewController *activityVC = [[UIActivityViewController alloc] initWithActivityItems:@[self.image] applicationActivities:@[]];
if (!canSaveToCameraRoll && !didShowWarning) {
didShowWarning = YES;
NSString *msg = @"Add 'NSPhotoLibraryUsageDescription' to this app's Info.plist to save images.";
[FLEXAlert makeAlert:^(FLEXAlert *make) {
make.title(@"Reminder").message(msg);
make.button(@"OK").handler(^(NSArray<NSString *> *strings) {
[self presentViewController:activityVC animated:YES completion:nil];
});
} showFrom:self];
} else {
[self presentViewController:activityVC animated:YES completion:nil];
}
}
@end

View File

@@ -0,0 +1,22 @@
//
// FHSRangeSlider.h
// FLEX
//
// Created by Tanner Bennett on 1/7/20.
// Copyright © 2020 FLEX Team. 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

View File

@@ -0,0 +1,201 @@
//
// FHSRangeSlider.m
// FLEX
//
// Created by Tanner Bennett on 1/7/20.
// Copyright © 2020 FLEX Team. 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)(UIImage *) = ^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

View File

@@ -0,0 +1,46 @@
//
// FHSSnapshotView.h
// FLEX
//
// Created by Tanner Bennett on 1/7/20.
// Copyright © 2020 FLEX Team. 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

View File

@@ -0,0 +1,304 @@
//
// FHSSnapshotView.m
// FLEX
//
// Created by Tanner Bennett on 1/7/20.
// Copyright © 2020 FLEX Team. 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 = nodes.depth;
nodes.snapshot.hidden = depth < min || max < depth;
}
}
@end

View File

@@ -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

View File

@@ -0,0 +1,194 @@
//
// FHSView.m
// FLEX
//
// Created by Tanner Bennett on 1/6/20.
//
#import "FHSView.h"
#import "FLEXUtility.h"
#import "NSArray+FLEX.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(^)(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

View File

@@ -0,0 +1,29 @@
//
// FHSViewController.h
// FLEX
//
// Created by Tanner Bennett on 1/6/20.
// Copyright © 2020 FLEX Team. 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

View File

@@ -0,0 +1,270 @@
//
// FHSViewController.m
// FLEX
//
// Created by Tanner Bennett on 1/6/20.
// Copyright © 2020 FLEX Team. All rights reserved.
//
#import "FHSViewController.h"
#import "FHSSnapshotView.h"
#import "FLEXHierarchyViewController.h"
#import "FLEXColor.h"
#import "FLEXAlert.h"
#import "FLEXWindow.h"
#import "FLEXResources.h"
#import "NSArray+FLEX.h"
#import "UIBarButtonItem+FLEX.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
flex_itemWithImage:FLEXResources.toggle2DIcon
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 flex_itemWithCustomView:snapshotView.spacingSlider],
UIBarButtonItem.flex_flexibleSpace,
[UIBarButtonItem
flex_itemWithImage:FLEXResources.moreIcon
target:self action:@selector(didPressOptionsButton:)
],
UIBarButtonItem.flex_flexibleSpace,
[UIBarButtonItem flex_itemWithCustomView: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 || !self.snapshots.count) return nil;
for (FHSViewSnapshot *snapshot in self.snapshots) {
FHSViewSnapshot *found = [snapshot snapshotForView:view];
if (found) {
return found;
}
}
// Error: we have snapshots but the view we requested is not in one
@throw NSInternalInconsistencyException;
return nil;
}
#pragma mark Events
- (void)didPressOptionsButton:(UIBarButtonItem *)sender {
[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 source:sender];
}
- (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

View File

@@ -0,0 +1,37 @@
//
// FHSViewSnapshot.h
// FLEX
//
// Created by Tanner Bennett on 1/9/20.
// Copyright © 2020 FLEX Team. 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

View File

@@ -0,0 +1,62 @@
//
// FHSViewSnapshot.m
// FLEX
//
// Created by Tanner Bennett on 1/9/20.
// Copyright © 2020 FLEX Team. All rights reserved.
//
#import "FHSViewSnapshot.h"
#import "NSArray+FLEX.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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,19 @@
//
// FLEXHierarchyTableViewCell.h
// Flipboard
//
// Created by Ryan Olson on 2014-05-02.
// Copyright (c) 2020 FLEX Team. All rights reserved.
//
#import <UIKit/UIKit.h>
@interface FLEXHierarchyTableViewCell : UITableViewCell
- (id)initWithReuseIdentifier:(NSString *)reuseIdentifier;
@property (nonatomic) NSInteger viewDepth;
@property (nonatomic) UIColor *randomColorTag;
@property (nonatomic) UIColor *indicatedViewColor;
@end

View File

@@ -0,0 +1,169 @@
//
// FLEXHierarchyTableViewCell.m
// Flipboard
//
// Created by Ryan Olson on 2014-05-02.
// Copyright (c) 2020 FLEX Team. All rights reserved.
//
#import "FLEXHierarchyTableViewCell.h"
#import "FLEXUtility.h"
#import "FLEXResources.h"
#import "FLEXColor.h"
@interface FLEXHierarchyTableViewCell ()
/// Indicates how deep the view is in the hierarchy
@property (nonatomic) UIView *depthIndicatorView;
/// Holds the color that visually distinguishes views from one another
@property (nonatomic) UIImageView *colorCircleImageView;
/// A checker-patterned view, used to help show the color of a view, like a photoshop canvas
@property (nonatomic) UIView *backgroundColorCheckerPatternView;
/// The subview of the checker pattern view which holds the actual color of the view
@property (nonatomic) UIView *viewBackgroundColorView;
@end
@implementation FLEXHierarchyTableViewCell
- (id)initWithReuseIdentifier:(NSString *)reuseIdentifier {
return [self initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:reuseIdentifier];
}
- (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
if (self) {
self.depthIndicatorView = [UIView new];
self.depthIndicatorView.backgroundColor = FLEXUtility.hierarchyIndentPatternColor;
[self.contentView addSubview:self.depthIndicatorView];
UIImage *defaultCircleImage = [FLEXUtility circularImageWithColor:UIColor.blackColor radius:5];
self.colorCircleImageView = [[UIImageView alloc] initWithImage:defaultCircleImage];
[self.contentView addSubview:self.colorCircleImageView];
self.textLabel.font = UIFont.flex_defaultTableCellFont;
self.detailTextLabel.font = UIFont.flex_defaultTableCellFont;
self.accessoryType = UITableViewCellAccessoryDetailButton;
// Use a pattern-based color to simplify application of the checker pattern
static UIColor *checkerPatternColor = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{
checkerPatternColor = [UIColor colorWithPatternImage:FLEXResources.checkerPattern];
});
self.backgroundColorCheckerPatternView = [UIView new];
self.backgroundColorCheckerPatternView.clipsToBounds = YES;
self.backgroundColorCheckerPatternView.layer.borderColor = FLEXColor.tertiaryBackgroundColor.CGColor;
self.backgroundColorCheckerPatternView.layer.borderWidth = 2.f / UIScreen.mainScreen.scale;
self.backgroundColorCheckerPatternView.backgroundColor = checkerPatternColor;
[self.contentView addSubview:self.backgroundColorCheckerPatternView];
self.viewBackgroundColorView = [UIView new];
[self.backgroundColorCheckerPatternView addSubview:self.viewBackgroundColorView];
}
return self;
}
- (void)setHighlighted:(BOOL)highlighted animated:(BOOL)animated {
UIColor *originalColour = self.viewBackgroundColorView.backgroundColor;
[super setHighlighted:highlighted animated:animated];
// UITableViewCell changes all subviews in the contentView to backgroundColor = clearColor.
// We want to preserve the hierarchy background color when highlighted.
self.depthIndicatorView.backgroundColor = FLEXUtility.hierarchyIndentPatternColor;
self.viewBackgroundColorView.backgroundColor = originalColour;
}
- (void)setSelected:(BOOL)selected animated:(BOOL)animated {
UIColor *originalColour = self.viewBackgroundColorView.backgroundColor;
[super setSelected:selected animated:animated];
// See setHighlighted above.
self.depthIndicatorView.backgroundColor = FLEXUtility.hierarchyIndentPatternColor;
self.viewBackgroundColorView.backgroundColor = originalColour;
}
- (void)layoutSubviews {
[super layoutSubviews];
const CGFloat kContentPadding = 6;
const CGFloat kDepthIndicatorWidthMultiplier = 4;
const CGFloat kViewColorIndicatorSize = 22;
const CGRect bounds = self.contentView.bounds;
const CGFloat centerY = CGRectGetMidY(bounds);
const CGFloat textLabelCenterY = CGRectGetMidY(self.textLabel.frame);
BOOL hideCheckerView = self.backgroundColorCheckerPatternView.hidden;
CGFloat maxWidth = CGRectGetMaxX(bounds);
maxWidth -= (hideCheckerView ? kContentPadding : (kViewColorIndicatorSize + kContentPadding * 2));
CGRect depthIndicatorFrame = self.depthIndicatorView.frame = CGRectMake(
kContentPadding, 0, self.viewDepth * kDepthIndicatorWidthMultiplier, CGRectGetHeight(bounds)
);
// Circle goes after depth, and its center Y = textLabel's center Y
CGRect circleFrame = self.colorCircleImageView.frame;
circleFrame.origin.x = CGRectGetMaxX(depthIndicatorFrame) + kContentPadding;
circleFrame.origin.y = FLEXFloor(textLabelCenterY - CGRectGetHeight(circleFrame) / 2.f);
self.colorCircleImageView.frame = circleFrame;
// Text label goes after random color circle, width extends to the edge
// of the contentView or to the padding before the color indicator view
CGRect textLabelFrame = self.textLabel.frame;
CGFloat textOriginX = CGRectGetMaxX(circleFrame) + kContentPadding;
textLabelFrame.origin.x = textOriginX;
textLabelFrame.size.width = maxWidth - textOriginX;
self.textLabel.frame = textLabelFrame;
// detailTextLabel leading edge lines up with the circle, and the
// width extends to the same max X as the same max X as the textLabel
CGRect detailTextLabelFrame = self.detailTextLabel.frame;
CGFloat detailOriginX = circleFrame.origin.x;
detailTextLabelFrame.origin.x = detailOriginX;
detailTextLabelFrame.size.width = maxWidth - detailOriginX;
self.detailTextLabel.frame = detailTextLabelFrame;
// Checker pattern view starts after the padding after the max X of textLabel,
// and is centered vertically within the entire contentView
self.backgroundColorCheckerPatternView.frame = CGRectMake(
CGRectGetMaxX(self.textLabel.frame) + kContentPadding,
centerY - kViewColorIndicatorSize / 2.f,
kViewColorIndicatorSize,
kViewColorIndicatorSize
);
// Background color view fills it's superview
self.viewBackgroundColorView.frame = self.backgroundColorCheckerPatternView.bounds;
self.backgroundColorCheckerPatternView.layer.cornerRadius = kViewColorIndicatorSize / 2.f;
}
- (void)setRandomColorTag:(UIColor *)randomColorTag {
if (![_randomColorTag isEqual:randomColorTag]) {
_randomColorTag = randomColorTag;
self.colorCircleImageView.image = [FLEXUtility circularImageWithColor:randomColorTag radius:6];
}
}
- (void)setViewDepth:(NSInteger)viewDepth {
if (_viewDepth != viewDepth) {
_viewDepth = viewDepth;
[self setNeedsLayout];
}
}
- (UIColor *)indicatedViewColor {
return self.viewBackgroundColorView.backgroundColor;
}
- (void)setIndicatedViewColor:(UIColor *)color {
self.viewBackgroundColorView.backgroundColor = color;
// Hide the checker pattern view if there is no background color
self.backgroundColorCheckerPatternView.hidden = color == nil;
[self setNeedsLayout];
}
@end

View File

@@ -0,0 +1,20 @@
//
// FLEXHierarchyTableViewController.h
// Flipboard
//
// Created by Ryan Olson on 2014-05-01.
// Copyright (c) 2020 FLEX Team. 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

View File

@@ -0,0 +1,253 @@
//
// FLEXHierarchyTableViewController.m
// Flipboard
//
// Created by Ryan Olson on 2014-05-01.
// Copyright (c) 2020 FLEX Team. All rights reserved.
//
#import "FLEXColor.h"
#import "FLEXHierarchyTableViewController.h"
#import "NSMapTable+FLEX_Subscripting.h"
#import "FLEXUtility.h"
#import "FLEXHierarchyTableViewCell.h"
#import "FLEXObjectExplorerViewController.h"
#import "FLEXObjectExplorerFactory.h"
#import "FLEXResources.h"
#import "FLEXWindow.h"
typedef NS_ENUM(NSUInteger, FLEXHierarchyScope) {
FLEXHierarchyScopeFullHierarchy,
FLEXHierarchyScopeViewsAtTap
};
@interface FLEXHierarchyTableViewController ()
@property (nonatomic) NSArray<UIView *> *allViews;
@property (nonatomic) NSMapTable<UIView *, NSNumber *> *depthsForViews;
@property (nonatomic) NSArray<UIView *> *viewsAtTap;
@property (nonatomic) NSArray<UIView *> *displayedViews;
@property (nonatomic, readonly) BOOL showScopeBar;
@end
@implementation FLEXHierarchyTableViewController
+ (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;
self.depthsForViews = depthsForViews;
self.viewsAtTap = viewsAtTap;
self.selectedView = selectedView;
self.title = @"View Hierarchy Tree";
}
return self;
}
- (void)viewDidLoad {
[super viewDidLoad];
// Preserve selection between presentations
self.clearsSelectionOnViewWillAppear = NO;
// A little more breathing room
self.tableView.rowHeight = 50.0;
self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
// Separator inset clashes with persistent cell selection
[self.tableView setSeparatorInset:UIEdgeInsetsZero];
self.showsSearchBar = YES;
self.showSearchBarInitially = YES;
// Using pinSearchBar on this screen causes a weird visual
// thing on the next view controller that gets pushed.
//
// self.pinSearchBar = YES;
self.searchBarDebounceInterval = kFLEXDebounceInstant;
self.automaticallyShowsSearchBarCancelButton = NO;
if (self.showScopeBar) {
self.searchController.searchBar.showsScopeBar = YES;
self.searchController.searchBar.scopeButtonTitles = @[@"Full Hierarchy", @"Views at Tap"];
self.selectedScope = FLEXHierarchyScopeViewsAtTap;
}
[self updateDisplayedViews];
}
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
[self disableToolbar];
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
[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)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 {
NSArray<UIView *> *candidateViews = nil;
if (self.showScopeBar) {
if (self.selectedScope == FLEXHierarchyScopeViewsAtTap) {
candidateViews = self.viewsAtTap;
} else if (self.selectedScope == FLEXHierarchyScopeFullHierarchy) {
candidateViews = self.allViews;
}
} else {
candidateViews = self.allViews;
}
if (self.searchText.length) {
self.displayedViews = [candidateViews filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(UIView *candidateView, NSDictionary<NSString *, id> *bindings) {
NSString *title = [FLEXUtility descriptionForView:candidateView includingFrame:NO];
NSString *candidateViewPointerAddress = [NSString stringWithFormat:@"%p", candidateView];
BOOL matchedViewPointerAddress = [candidateViewPointerAddress rangeOfString:self.searchText options:NSCaseInsensitiveSearch].location != NSNotFound;
BOOL matchedViewTitle = [title rangeOfString:self.searchText options:NSCaseInsensitiveSearch].location != NSNotFound;
return matchedViewPointerAddress || matchedViewTitle;
}]];
} else {
self.displayedViews = candidateViews;
}
[self.tableView reloadData];
}
- (void)setSelectedView:(UIView *)selectedView {
_selectedView = selectedView;
if (self.isViewLoaded) {
[self trySelectCellForSelectedView];
}
}
#pragma mark - Search Bar / Scope Bar
- (BOOL)showScopeBar {
return self.viewsAtTap.count > 0;
}
- (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.
if (!self.searchController.searchBar.isFirstResponder) {
[self trySelectCellForSelectedView];
}
}
#pragma mark - Table View Data Source
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return self.displayedViews.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
static NSString *CellIdentifier = @"Cell";
FLEXHierarchyTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
if (!cell) {
cell = [[FLEXHierarchyTableViewCell alloc] initWithReuseIdentifier:CellIdentifier];
}
UIView *view = self.displayedViews[indexPath.row];
cell.textLabel.text = [FLEXUtility descriptionForView:view includingFrame:NO];
cell.detailTextLabel.text = [FLEXUtility detailDescriptionForView:view];
cell.randomColorTag = [FLEXUtility consistentRandomColorForObject:view];
cell.viewDepth = self.depthsForViews[view].integerValue;
cell.indicatedViewColor = view.backgroundColor;
if (view.isHidden || view.alpha < 0.01) {
cell.textLabel.textColor = FLEXColor.deemphasizedTextColor;
cell.detailTextLabel.textColor = FLEXColor.deemphasizedTextColor;
} else {
cell.textLabel.textColor = FLEXColor.primaryTextColor;
cell.detailTextLabel.textColor = FLEXColor.primaryTextColor;
}
return cell;
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
_selectedView = self.displayedViews[indexPath.row]; // Don't scroll, avoid setter
if (self.didSelectRowAction) {
self.didSelectRowAction(_selectedView);
}
}
- (void)tableView:(UITableView *)tableView accessoryButtonTappedForRowWithIndexPath:(NSIndexPath *)indexPath {
UIView *drillInView = self.displayedViews[indexPath.row];
FLEXObjectExplorerViewController *viewExplorer = [FLEXObjectExplorerFactory explorerViewControllerForObject:drillInView];
[self.navigationController pushViewController:viewExplorer animated:YES];
}
@end