Skip to content

Instantly share code, notes, and snippets.

@steventroughtonsmith
Created March 1, 2020 17:21
Show Gist options
  • Save steventroughtonsmith/5a82c953f88b92969195bf61bd7bf263 to your computer and use it in GitHub Desktop.
Save steventroughtonsmith/5a82c953f88b92969195bf61bd7bf263 to your computer and use it in GitHub Desktop.
Simplified iOS menu view wired up to UIMenuBuilder
//
// 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
//
// 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
@rodydavis
Copy link

Wow awesome!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment