Instantly share code, notes, and snippets.
Created
March 1, 2020 17:21
-
Star
(35)
35
You must be signed in to star a gist -
Fork
(2)
2
You must be signed in to fork a gist
-
Save steventroughtonsmith/5a82c953f88b92969195bf61bd7bf263 to your computer and use it in GitHub Desktop.
Simplified iOS menu view wired up to UIMenuBuilder
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// MRDMenuView.h | |
// MobileRadio | |
// | |
// Created by Steven Troughton-Smith on 29/02/2020. | |
// Copyright © 2020 High Caffeine Content. All rights reserved. | |
// | |
#import <UIKit/UIKit.h> | |
NS_ASSUME_NONNULL_BEGIN | |
@class MRDMenuContentsView; | |
@interface MRDMenuView : UIView <UIMenuBuilder> | |
{ | |
MRDMenuContentsView *contentsView; | |
UILabel *rootMenuTitleLabel; | |
UIImageView *rootMenuImageView; | |
} | |
@property NSMutableDictionary *menu; | |
@property BOOL open; | |
@end | |
NS_ASSUME_NONNULL_END |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// MRDMenuView.m | |
// MobileRadio | |
// | |
// Created by Steven Troughton-Smith on 29/02/2020. | |
// Copyright © 2020 High Caffeine Content. All rights reserved. | |
// | |
#import "MRDMenuView.h" | |
#import "AppDelegate.h" | |
#define MRDMenuCornerRadius 8.0 | |
@interface MRDMenuItemView : UIView | |
{ | |
UILabel *titleLabel; | |
UILabel *shortcutLabel; | |
} | |
@property NSString *title; | |
@property NSString *shortcut; | |
@property BOOL separator; | |
@property BOOL enabled; | |
@property (strong) UIMenuElement *menu; | |
@end | |
@implementation MRDMenuItemView | |
- (instancetype)initWithFrame:(CGRect)frame | |
{ | |
self = [super initWithFrame:frame]; | |
if (self) { | |
titleLabel = [UILabel new]; | |
shortcutLabel = [UILabel new]; | |
shortcutLabel.textAlignment = NSTextAlignmentRight; | |
[self addSubview:titleLabel]; | |
[self addSubview:shortcutLabel]; | |
self.enabled = YES; | |
} | |
return self; | |
} | |
- (void)layoutSubviews | |
{ | |
CGFloat padding = 20; | |
CGFloat shortcutWidth = 60; | |
CGFloat maxWidth = self.bounds.size.width -(padding *2) - shortcutWidth; | |
titleLabel.frame = CGRectMake(padding, 0, maxWidth, self.bounds.size.height); | |
shortcutLabel.frame = CGRectMake(self.bounds.size.width-shortcutWidth-padding, 0, shortcutWidth, self.bounds.size.height); | |
titleLabel.text = self.title; | |
shortcutLabel.text = self.shortcut; | |
if (self.separator) | |
{ | |
self.backgroundColor = [UIColor secondarySystemFillColor]; | |
} | |
else | |
{ | |
self.backgroundColor = nil; | |
} | |
titleLabel.enabled = self.enabled; | |
shortcutLabel.enabled = self.enabled; | |
} | |
-(void)startSelectionAnimation | |
{ | |
if (self.separator) | |
return;; | |
[self performCommand]; | |
[self unhighlight]; | |
CGFloat blinkDelay = 0.08; | |
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(blinkDelay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ | |
[self highlight]; | |
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(blinkDelay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ | |
[self unhighlight]; | |
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(blinkDelay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ | |
[[NSNotificationCenter defaultCenter] postNotificationName:@"MRDMenuItemSelected" object:self]; | |
}); | |
}); | |
}); | |
} | |
-(void)performCommand | |
{ | |
if (!self.enabled) | |
return; | |
if ([self.menu isKindOfClass:NSClassFromString(@"UIKeyCommand")]) | |
{ | |
UIKeyCommand *command = (UIKeyCommand *)self.menu; | |
#pragma clang diagnostic push | |
#pragma clang diagnostic ignored "-Warc-performSelector-leaks" | |
[[[UIApplication sharedApplication] delegate] performSelector:[command action] withObject:self]; | |
#pragma clang diagnostic pop | |
} | |
} | |
-(void)highlight | |
{ | |
if (self.separator || !self.enabled) | |
return; | |
self.backgroundColor = [UIColor systemBlueColor]; | |
titleLabel.textColor = [UIColor whiteColor]; | |
shortcutLabel.textColor = [UIColor whiteColor]; | |
} | |
-(void)unhighlight | |
{ | |
if (self.separator || !self.enabled) | |
return;; | |
self.backgroundColor = nil; | |
titleLabel.textColor = [UIColor labelColor]; | |
shortcutLabel.textColor = [UIColor labelColor]; | |
} | |
@end | |
@interface MRDMenuContentsView : UIView | |
{ | |
UIVisualEffectView *effectView; | |
UIView *shadowView; | |
UIView *headerView; | |
} | |
@property NSArray <MRDMenuItemView *>*menuItemViews; | |
-(void)selectPosition:(CGPoint)p; | |
-(void)setHighlightedPosition:(CGPoint)p; | |
@end | |
@implementation MRDMenuContentsView | |
- (instancetype)initWithFrame:(CGRect)frame | |
{ | |
self = [super initWithFrame:frame]; | |
if (self) | |
{ | |
effectView = [[UIVisualEffectView alloc] initWithEffect:[UIBlurEffect effectWithStyle:UIBlurEffectStyleProminent]]; | |
shadowView = [UIView new]; | |
headerView = [UIView new]; | |
headerView.backgroundColor = [UIColor systemBlueColor]; | |
[self addSubview:shadowView]; | |
[self addSubview:effectView]; | |
[[effectView contentView] addSubview:headerView]; | |
shadowView.layer.shadowOpacity = 0.4; | |
shadowView.layer.shadowRadius = 7; | |
shadowView.layer.shadowColor = [UIColor colorWithRed:103/255 green:165/255 blue:162/255 alpha:1.].CGColor; | |
} | |
return self; | |
} | |
-(UIBezierPath *)computePathWithParentView:(UIView *)parent | |
{ | |
/* Ported from https://github.com/TwoLivesLeft/Menu */ | |
CGRect localViewBounds; | |
UIRectCorner lowerRectCorners; | |
localViewBounds = parent.bounds; | |
lowerRectCorners = (UIRectCornerTopRight | UIRectCornerBottomLeft | UIRectCornerBottomRight); | |
UIBezierPath *topPath = [UIBezierPath bezierPathWithRoundedRect:localViewBounds byRoundingCorners:(UIRectCornerTopLeft | UIRectCornerTopRight) cornerRadii:CGSizeMake(MRDMenuCornerRadius, MRDMenuCornerRadius)]; | |
UIBezierPath *midPath = [UIBezierPath new]; | |
[midPath moveToPoint:CGPointMake(localViewBounds.origin.x, localViewBounds.origin.y + localViewBounds.size.height)]; | |
[midPath addLineToPoint:CGPointMake(localViewBounds.origin.x, localViewBounds.origin.y + localViewBounds.size.height + MRDMenuCornerRadius)]; | |
[midPath addLineToPoint:CGPointMake(localViewBounds.origin.x+localViewBounds.size.width+MRDMenuCornerRadius, localViewBounds.origin.y + localViewBounds.size.height + MRDMenuCornerRadius)]; | |
[midPath addArcWithCenter:CGPointMake(localViewBounds.origin.x + localViewBounds.size.width+MRDMenuCornerRadius, localViewBounds.origin.y + localViewBounds.size.height) radius:MRDMenuCornerRadius startAngle:M_PI_2 endAngle:M_PI clockwise:YES]; | |
[midPath addLineToPoint:CGPointMake(localViewBounds.origin.x, localViewBounds.origin.y + localViewBounds.size.height)]; | |
[midPath closePath]; | |
CGFloat yOffset = localViewBounds.origin.y + localViewBounds.size.height + MRDMenuCornerRadius; | |
UIBezierPath *bottomPath = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, yOffset, self.bounds.size.width, self.bounds.size.height - yOffset) byRoundingCorners:lowerRectCorners cornerRadii:CGSizeMake(MRDMenuCornerRadius, MRDMenuCornerRadius)]; | |
[topPath appendPath:midPath]; | |
[topPath appendPath:bottomPath]; | |
return topPath; | |
} | |
-(void)generateMaskAndShadow | |
{ | |
UIBezierPath *path = [self computePathWithParentView:self.superview]; | |
CAShapeLayer *shapeLayer = [CAShapeLayer layer]; | |
shapeLayer.path = path.CGPath; | |
effectView.layer.mask = shapeLayer; | |
[path applyTransform:CGAffineTransformMakeTranslation(20, 20)]; | |
CALayer *shadowLayer = shadowView.layer; | |
shadowLayer.shadowPath = path.CGPath; | |
shadowLayer.shadowOffset = CGSizeMake(0, 6); | |
UIGraphicsImageRenderer *imageRenderer = [[UIGraphicsImageRenderer alloc] initWithSize:shadowView.bounds.size]; | |
UIImage *shadowMask = [imageRenderer imageWithActions:^(UIGraphicsImageRendererContext * _Nonnull rendererContext) { | |
[[UIColor whiteColor] setFill]; | |
[rendererContext fillRect:shadowView.bounds]; | |
[path fillWithBlendMode:kCGBlendModeClear alpha:1.0]; | |
}]; | |
CALayer *imageMask = [CALayer layer]; | |
imageMask.frame = shadowView.bounds; | |
imageMask.contents = (__bridge id _Nullable)(shadowMask.CGImage); | |
shadowLayer.mask = imageMask; | |
} | |
-(void)relayoutContents | |
{ | |
CGFloat y = self.superview.frame.size.height + MRDMenuCornerRadius*3; // inner and outer at the L join, plus bottom radius | |
CGFloat longestItemTitle = 0; | |
for (MRDMenuItemView *item in self.menuItemViews) | |
{ | |
CGSize sz = [item.title sizeWithAttributes:@{NSFontAttributeName: [UIFont systemFontOfSize:17]}]; | |
sz.width += 20+60+20; | |
if (sz.width > longestItemTitle) | |
{ | |
longestItemTitle = sz.width; | |
} | |
} | |
for (MRDMenuItemView *item in self.menuItemViews) | |
{ | |
CGFloat menuHeight = 40; | |
if (item.separator) | |
menuHeight = 2; | |
item.frame = CGRectMake(0, y, longestItemTitle, menuHeight); | |
[[effectView contentView] addSubview:item]; | |
y+= menuHeight; | |
} | |
y += MRDMenuCornerRadius; | |
self.frame = CGRectMake(self.frame.origin.x, self.frame.origin.y, longestItemTitle, y); | |
effectView.frame = self.bounds; | |
shadowView.frame = CGRectInset(self.bounds, -20, -20); | |
headerView.frame = self.superview.bounds; | |
CGRect hf = headerView.frame; | |
hf.size.height += MRDMenuCornerRadius*2; | |
hf.size.width = self.frame.size.width; | |
headerView.frame = hf; | |
} | |
-(void)selectPosition:(CGPoint)p | |
{ | |
for (MRDMenuItemView *view in self.menuItemViews) | |
{ | |
CGPoint p1 = [self convertPoint:p toView:view]; | |
if ([view pointInside:p1 withEvent:nil]) | |
{ | |
[view startSelectionAnimation]; | |
} | |
} | |
} | |
-(void)setHighlightedPosition:(CGPoint)p | |
{ | |
for (MRDMenuItemView *view in self.menuItemViews) | |
{ | |
CGPoint p1 = [self convertPoint:p toView:view]; | |
if ([view pointInside:p1 withEvent:nil]) | |
{ | |
[view highlight]; | |
} | |
else | |
{ | |
[view unhighlight]; | |
} | |
} | |
} | |
@end | |
@implementation MRDMenuView | |
- (instancetype)initWithFrame:(CGRect)frame | |
{ | |
self = [super initWithFrame:frame]; | |
if (self) { | |
self.backgroundColor = nil; | |
self.opaque = NO; | |
rootMenuTitleLabel = [UILabel new]; /* Unused */ | |
rootMenuTitleLabel.text = NSLocalizedString(@"SOFTWARE_MENU_TITLE", nil); | |
rootMenuTitleLabel.textAlignment = NSTextAlignmentCenter; | |
rootMenuImageView = [UIImageView new]; | |
rootMenuImageView.contentMode = UIViewContentModeCenter; | |
rootMenuImageView.tintColor = [[UIColor labelColor] colorWithAlphaComponent:0.7]; | |
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:24 weight:UIImageSymbolWeightSemibold]; | |
UIImage *icon = [UIImage systemImageNamed:@"command" withConfiguration:config]; | |
rootMenuImageView.image = icon; | |
[self addSubview:rootMenuImageView]; | |
UILongPressGestureRecognizer *recognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPress:)]; | |
recognizer.minimumPressDuration = 0; | |
[self addGestureRecognizer:recognizer]; | |
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(hideContents) name:@"MRDMenuItemSelected" object:nil]; | |
} | |
return self; | |
} | |
- (void)layoutSubviews | |
{ | |
[super layoutSubviews]; | |
rootMenuTitleLabel.frame = self.bounds; | |
rootMenuImageView.frame = self.bounds; | |
} | |
- (void)drawRect:(CGRect)rect | |
{ | |
[[UIColor secondarySystemFillColor] setFill]; | |
[[UIBezierPath bezierPathWithRoundedRect:CGRectMake(20, self.bounds.size.height-3, self.bounds.size.width-40, 3) cornerRadius:2] fill]; | |
} | |
#pragma mark - Menu Builder | |
-(id)system | |
{ | |
return self; | |
} | |
- (void)insertChildMenu:(nonnull UIMenu *)childMenu atEndOfMenuForIdentifier:(nonnull UIMenuIdentifier)parentIdentifier | |
{ | |
UIMenu *parentMenu = self.menu[parentIdentifier]; | |
parentMenu = [parentMenu menuByReplacingChildren:[parentMenu.children arrayByAddingObject:childMenu]]; | |
self.menu[parentIdentifier] = parentMenu; | |
} | |
- (void)insertChildMenu:(nonnull UIMenu *)childMenu atStartOfMenuForIdentifier:(nonnull UIMenuIdentifier)parentIdentifier | |
{ | |
UIMenu *parentMenu = self.menu[parentIdentifier]; | |
parentMenu = [parentMenu menuByReplacingChildren:[@[childMenu] arrayByAddingObjectsFromArray:parentMenu.children]]; | |
self.menu[parentIdentifier] = parentMenu; | |
} | |
- (nullable UIMenu *)menuForIdentifier:(nonnull UIMenuIdentifier)identifier | |
{ | |
return [self.menu objectForKey:identifier]; | |
} | |
- (void)removeMenuForIdentifier:(nonnull UIMenuIdentifier)removedIdentifier | |
{ | |
[self.menu removeObjectForKey:removedIdentifier]; | |
} | |
- (void)replaceMenuForIdentifier:(nonnull UIMenuIdentifier)replacedIdentifier withMenu:(nonnull UIMenu *)replacementMenu | |
{ | |
self.menu[replacedIdentifier] = replacementMenu; | |
} | |
#pragma mark - Unused | |
- (void)insertSiblingMenu:(nonnull UIMenu *)siblingMenu afterMenuForIdentifier:(nonnull UIMenuIdentifier)siblingIdentifier | |
{ | |
} | |
- (void)insertSiblingMenu:(nonnull UIMenu *)siblingMenu beforeMenuForIdentifier:(nonnull UIMenuIdentifier)siblingIdentifier | |
{ | |
} | |
- (void)replaceChildrenOfMenuForIdentifier:(nonnull UIMenuIdentifier)parentIdentifier fromChildrenBlock:(NSArray<UIMenuElement *> *(NS_NOESCAPE ^)(NSArray<UIMenuElement *> *))childrenBlock | |
{ | |
} | |
- (nullable UIAction *)actionForIdentifier:(nonnull UIActionIdentifier)identifier | |
{ | |
return nil; | |
} | |
- (nullable UICommand *)commandForAction:(nonnull SEL)action propertyList:(nullable id)propertyList | |
{ | |
return nil; | |
} | |
#pragma mark - Menu | |
-(void)populateInitialMenu | |
{ | |
/* iOS, unlike macOS, doesn't start with any menu bar menus */ | |
self.menu = [NSMutableDictionary dictionary]; | |
self.menu[UIMenuFile] = [UIMenu menuWithTitle:NSLocalizedString(@"File", nil) children:@[]]; | |
self.menu[UIMenuEdit] = [UIMenu menuWithTitle:NSLocalizedString(@"Edit", nil) children:@[]]; | |
self.menu[UIMenuView] = [UIMenu menuWithTitle:NSLocalizedString(@"View", nil) children:@[]]; | |
self.menu[UIMenuHelp] = [UIMenu menuWithTitle:NSLocalizedString(@"Help", nil) children:@[]]; | |
} | |
-(NSArray <MRDMenuItemView *>*)_buildMenuItemViewsForMenu:(UIMenu *)element | |
{ | |
NSMutableArray *array = [NSMutableArray array]; | |
for (UIMenuElement *child in element.children) | |
{ | |
MRDMenuItemView *menuItemView = [MRDMenuItemView new]; | |
if ([child isKindOfClass:NSClassFromString(@"UIKeyCommand")]) | |
{ | |
UIKeyCommand *command = (UIKeyCommand *)child; | |
NSMutableString *shortcut = [NSMutableString string]; | |
NSMutableString *title = [NSMutableString string]; | |
if (command.modifierFlags & UIKeyModifierControl) | |
[shortcut appendString:@"⌃"]; | |
if (command.modifierFlags & UIKeyModifierAlternate) | |
[shortcut appendString:@"⌥"]; | |
if (command.modifierFlags & UIKeyModifierShift) | |
[shortcut appendString:@"⇧"]; | |
if (command.modifierFlags & UIKeyModifierCommand) | |
[shortcut appendString:@"⌘"]; | |
[shortcut appendString:command.input]; | |
menuItemView.shortcut = [NSString stringWithFormat:@"%@", shortcut]; | |
/* | |
if (command.state == UIMenuElementStateOn) | |
[title appendString:@" "]; | |
else if (command.state == UIMenuElementStateMixed) | |
[title appendString:@"- "]; | |
*/ | |
if (command.attributes & UIMenuElementAttributesDisabled) | |
menuItemView.enabled = NO; | |
[title appendString:command.title]; | |
menuItemView.title = [NSString stringWithFormat:@"%@", title]; | |
menuItemView.menu = command; | |
} | |
if ([child isKindOfClass:NSClassFromString(@"UIMenu")]) | |
{ | |
[array addObjectsFromArray:[self _buildMenuItemViewsForMenu:(UIMenu *)child]]; | |
menuItemView.menu = child; | |
} | |
else | |
[array addObject:menuItemView]; | |
} | |
return [NSArray arrayWithArray:array]; | |
} | |
#pragma mark - Input | |
- (void)showContents | |
{ | |
if (self.open) | |
return; | |
[self populateInitialMenu]; | |
[(AppDelegate *)([[UIApplication sharedApplication] delegate]) buildMenuWithBuilder:self]; | |
contentsView = [MRDMenuContentsView new]; | |
NSMutableArray *items = [NSMutableArray array]; | |
for (NSString *identifier in @[UIMenuFile, UIMenuView, UIMenuHelp]) | |
{ | |
UIMenu *menu = self.menu[identifier]; | |
[items addObjectsFromArray:[self _buildMenuItemViewsForMenu:menu]]; | |
if (![identifier isEqualToString:UIMenuHelp]) | |
{ | |
MRDMenuItemView *menuItemView = [MRDMenuItemView new]; | |
menuItemView.separator = YES; | |
[items addObject:menuItemView]; | |
} | |
} | |
contentsView.menuItemViews = items; | |
rootMenuImageView.tintColor = [UIColor whiteColor]; | |
[self insertSubview:contentsView belowSubview:rootMenuImageView]; | |
[contentsView relayoutContents]; | |
[contentsView generateMaskAndShadow]; | |
self.open = YES; | |
} | |
-(void)hideContents | |
{ | |
[UIView animateWithDuration:0.3 animations:^{ | |
self->contentsView.alpha = 0; | |
self->rootMenuImageView.tintColor = [[UIColor labelColor] colorWithAlphaComponent:0.7]; | |
} completion:^(BOOL finished) { | |
self->contentsView.frame = self.bounds; | |
self.open = NO; | |
}]; | |
} | |
-(void)longPress:(UILongPressGestureRecognizer *)recognizer | |
{ | |
if (contentsView) | |
{ | |
CGPoint p1 = [recognizer locationInView:self]; | |
CGPoint p2 = [self convertPoint:p1 toView:contentsView]; | |
[contentsView setHighlightedPosition:CGPointMake(p2.x, p1.y)]; | |
} | |
switch (recognizer.state) | |
{ | |
case UIGestureRecognizerStateBegan: | |
[self showContents]; | |
break; | |
case UIGestureRecognizerStateCancelled: | |
case UIGestureRecognizerStateEnded: | |
{ | |
CGPoint p2 = [self convertPoint:[recognizer locationInView:self] toView:contentsView]; | |
if ([contentsView pointInside:p2 withEvent:nil]) | |
{ | |
[contentsView selectPosition:p2]; | |
} | |
CGRect menuContentsRect = contentsView.bounds; | |
menuContentsRect.origin.y += self.bounds.size.height + MRDMenuCornerRadius*2; | |
menuContentsRect.size.height -= (self.bounds.size.height + MRDMenuCornerRadius*4); | |
CGPoint p3 = [recognizer locationInView:self]; | |
if (!CGRectContainsPoint(menuContentsRect, p3)) | |
{ | |
[self hideContents]; | |
} | |
} | |
default: | |
break; | |
} | |
} | |
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event | |
{ | |
if (contentsView) | |
{ | |
CGPoint p = [self convertPoint:point toView:contentsView]; | |
return [contentsView pointInside:p withEvent:nil]; | |
} | |
else | |
{ | |
return [super pointInside:point withEvent:event]; | |
} | |
} | |
@end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Wow awesome!