Skip to content

Instantly share code, notes, and snippets.

@douglashill
Last active April 15, 2023 15:45
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save douglashill/7b16af09e66574f1fc5c27f540b1b08a to your computer and use it in GitHub Desktop.
Save douglashill/7b16af09e66574f1fc5c27f540b1b08a to your computer and use it in GitHub Desktop.
Swizzles the iOS contextual menu and share sheet to improve usability by showing the icon on the leading side. Read more: https://douglashill.co/menu-icon-swizzling/
// Douglas Hill, March 2020
// Code for the article at https://douglashill.co/menu-icon-swizzling/
import UIKit
struct MenuAlignmentFixError: Error, CustomStringConvertible {
let description: String
}
/// Swizzles internal UIKit classes for the contextual menu and share sheet
/// to improve usability by showing the icon on the leading side.
///
/// Throws errors immediately if the swizzles could not be applied.
/// Reports errors that happen later on (when the views are laying out) using the optional error handler.
///
/// Tested on iOS 13 and 14.0.
func applyMenuAlignmentSwizzles(errorHandler: @escaping (UIView, Error) -> Void = {_,_ in}) throws {
// Look up the classes.
let contextMenuActionViewClassName = "_UIContextMenuActionView"
guard let contextMenuActionViewClass = NSClassFromString(contextMenuActionViewClassName) else {
throw MenuAlignmentFixError(description: "Could not look up \(contextMenuActionViewClassName).")
}
guard contextMenuActionViewClass.isSubclass(of: UIView.self) else {
throw MenuAlignmentFixError(description: "\(contextMenuActionViewClassName) is not a subclass of \(UIView.self).")
}
let activityActionGroupCellClassName = "UIActivityActionGroupCell"
guard let activityActionGroupCellClass = NSClassFromString(activityActionGroupCellClassName), activityActionGroupCellClass.isSubclass(of: UICollectionViewCell.self) else {
throw MenuAlignmentFixError(description: "Could not look up \(activityActionGroupCellClassName).")
}
guard activityActionGroupCellClass.isSubclass(of: UICollectionViewCell.self) else {
throw MenuAlignmentFixError(description: "\(activityActionGroupCellClassName) is not a subclass of \(UICollectionViewCell.self).")
}
// Apply the swizzles.
let didSwizzleContextMenu = swizzleVoidVoidMethod(contextMenuActionViewClass, #selector(UIView.updateConstraints)) { object in
let view = object as! UIView
do {
try fixMenuLayout(inContextMenuActionView: view)
} catch {
errorHandler(view, error)
}
}
guard didSwizzleContextMenu else {
throw MenuAlignmentFixError(description: "Could not set up fixes in \(contextMenuActionViewClass).")
}
let didSwizzleActionGroupCell = swizzleVoidVoidMethod(activityActionGroupCellClass, #selector(UIView.updateConstraints)) { object in
let cell = object as! UICollectionViewCell
do {
try fixMenuLayout(inActivityActionGroupCell: cell)
} catch {
errorHandler(cell, error)
}
}
guard didSwizzleActionGroupCell else {
throw MenuAlignmentFixError(description: "Could not set up fixes in \(activityActionGroupCellClass).")
}
}
/// Tries to adjust layout constraints so the icons are on the leading side of the text instead of the trailing side.
/// Does nothing if fix has already been applied, and throws if the fix could not be applied.
private func fixMenuLayout(inContextMenuActionView contextMenuActionView: UIView) throws {
/*
As of iOS 13.4, there are two cases: with or without an image.
With an image, the view hierarchy is:
ContextMenuActionView
| StackView; frame = (16 13; 59 25.5)
| | Label; frame = (0 0; 59 25.5)
| ImageView; frame = (82 11.5; 23 26)
Horizontal constraints:
H:|-(16)-[StackView] (names: '|':ContextMenuActionView)
StackView.trailing <= ImageView.centerX - 18.25
ImageView.centerX == ContextMenuActionView.trailing - 30.25
Vertical constraints:
StackView.firstBaseline == ContextMenuActionView.top + 33
StackView.lastBaseline == ContextMenuActionView.bottom - 18
ImageView.centerY == ContextMenuActionView.centerY
- - - - -
Without an image, the view hierarchy is:
ContextMenuActionView
| StackView; frame = (16 13; 59 25.5)
| | Label; frame = (0 0; 59 25.5)
Horizontal constraints:
H:|-(16)-[StackView] (names: '|':ContextMenuActionView)
StackView.trailing <= ContextMenuActionView.trailing - 16
Vertical constraints:
StackView.firstBaseline == ContextMenuActionView.top + 33
StackView.lastBaseline == ContextMenuActionView.bottom - 18
- - - - -
There is an unsatisfiable constraints warning log, but it happens with or without the modifications here.
It’s in the vertical direction while all the modifications here are horizontal.
It looks like it’s due to the view being laid out with a manually set frame height of zero during setup.
NSAutoresizingMaskLayoutConstraint h=--& v=--& UIInterfaceActionGroupView.height == 0
NSLayoutConstraint groupView.actionsSequence....height >= 66
*/
var constraintsToDeactivate: [NSLayoutConstraint] = []
var constraintsToActivate: [NSLayoutConstraint] = []
let stackViews = contextMenuActionView.subviews.compactMap { $0 as? UIStackView }
guard stackViews.count == 1 else {
throw MenuAlignmentFixError(description: "Expected 1 stack view but there are \(stackViews.count).")
}
let stackView = stackViews[0]
let labels = stackView.subviews.compactMap { $0 as? UILabel }
guard labels.count == 1 else {
throw MenuAlignmentFixError(description: "Expected 1 label but there are \(labels.count).")
}
let imageViews = contextMenuActionView.subviews.compactMap { $0 as? UIImageView }
guard imageViews.count <= 1 else {
throw MenuAlignmentFixError(description: "Expected 0 or 1 image views, but there are \(imageViews.count).")
}
let constraints = contextMenuActionView.constraints
if constraints.isEmpty {
// New on iOS 14 this happens once and then it works on the second time.
// So don’t report an error in this case.
return
}
let imageCentreFromEdge: CGFloat = 28
let labelLeadingFromEdge: CGFloat = 2 * imageCentreFromEdge
let minTrailingMargin: CGFloat = 16
if let imageView = imageViews.first {
let desiredConstraints = [
imageView.centerXAnchor.constraint(equalTo: contextMenuActionView.leadingAnchor, constant: imageCentreFromEdge),
stackView.leadingAnchor.constraint(equalTo: contextMenuActionView.leadingAnchor, constant: labelLeadingFromEdge),
contextMenuActionView.trailingAnchor.constraint(greaterThanOrEqualTo: stackView.trailingAnchor, constant: minTrailingMargin),
]
if constraints.containsConstraints(desiredConstraints) {
// The fix was already applied.
return
}
guard let leadingConstraint = constraints.single(where: { $0.isBetweenAnchor(stackView.leadingAnchor, andAnchor: contextMenuActionView.leadingAnchor) }) else {
throw MenuAlignmentFixError(description: "Unexpected number of matching leading constraints.")
}
guard let trailingConstraint = constraints.single(where: { $0.isBetweenAnchor(imageView.centerXAnchor, andAnchor: contextMenuActionView.trailingAnchor) }) else {
throw MenuAlignmentFixError(description: "Unexpected number of matching trailing constraints.")
}
guard let noOverlapConstraint = constraints.single(where: { $0.isBetweenAnchor(stackView.trailingAnchor, andAnchor: imageView.centerXAnchor) }) else {
throw MenuAlignmentFixError(description: "Unexpected number of matching no-overlap constraints.")
}
constraintsToDeactivate += [leadingConstraint, trailingConstraint, noOverlapConstraint]
constraintsToActivate += desiredConstraints
} else {
let desiredConstraints = [
stackView.leadingAnchor.constraint(equalTo: contextMenuActionView.leadingAnchor, constant: labelLeadingFromEdge),
contextMenuActionView.trailingAnchor.constraint(greaterThanOrEqualTo: stackView.trailingAnchor, constant: minTrailingMargin)
]
if constraints.containsConstraints(desiredConstraints) {
// The fix was already applied.
return
}
guard let leadingConstraint = constraints.single(where: { $0.isBetweenAnchor(stackView.leadingAnchor, andAnchor: contextMenuActionView.leadingAnchor) }) else {
throw MenuAlignmentFixError(description: "Unexpected number of matching leading constraints.")
}
guard let trailingConstraint = constraints.single(where: { $0.isBetweenAnchor(stackView.trailingAnchor, andAnchor: contextMenuActionView.trailingAnchor) }) else {
throw MenuAlignmentFixError(description: "Unexpected number of matching trailing constraints.")
}
constraintsToDeactivate += [leadingConstraint, trailingConstraint]
constraintsToActivate += desiredConstraints
}
NSLayoutConstraint.deactivate(constraintsToDeactivate)
NSLayoutConstraint.activate(constraintsToActivate)
}
/// Tries to adjust layout constraints so the icons are on the leading side of the text instead of the trailing side.
/// Does nothing if fix has already been applied, and throws if the fix could not be applied.
private func fixMenuLayout(inActivityActionGroupCell cell: UICollectionViewCell) throws {
/*
As of iOS 13.4, there are plain UIViews mirroring the frame of the image view, label and container. The view hierarchy is:
ActivityActionGroupCell; frame = (20 190; 335 52)
| ContentView; frame = (0 0; 335 52)
| | VisualEffectView; frame = (0 0; 243 53)
| | | VisualEffectContentView; frame = (0 0; 243 53)
| | ContainerMirror; frame = (0 0; 243 53)
| | Label; frame = (16 15; 165.5 23)
| | ImageView; frame = (197.5 13; 34 27.5)
| | ImageMirror; frame = (197.5 9.5; 34 34)
| | LabelMirror; frame = (16 15; 165.5 23)
// Horizontal constraints
Label.safeAreaLayoutGuide.leading == VisualEffectView.layoutMarginsGuide.leading + 8 // The safeAreaLayoutGuide was present in iOS 13 but not in iOS 14.
H:[Label]-(16)-[ImageView]
H:[Label]-(16)-[ImageMirror]
VisualEffectView.trailing == ImageView.centerX + 28.6738
VisualEffectView.trailing == ImageMirror.centerX + 28.6738
// Vertical constraints
V:|-(15)-[Label] (names: '|':ContentView)
V:[Label]-(15)-| (names: '|':ContentView)
ImageView.centerY == ContentView.centerY
ImageMirror.centerY == ContentView.centerY
// Plus these boring constraints
VisualEffectView.width == ContentView.width
VisualEffectView.height == ContentView.height
VisualEffectView.centerX == ContentView.centerX
VisualEffectView.centerY == ContentView.centerY
ContainerMirror.width == ContentView.width
ContainerMirror.height == ContentView.height
ContainerMirror.centerX == ContentView.centerX
ContainerMirror.centerY == ContentView.centerY
LabelMirror.width == Label.width
LabelMirror.height == Label.height
LabelMirror.centerY == Label.centerY
LabelMirror.centerX == Label.centerX
*/
var constraintsToDeactivate: [NSLayoutConstraint] = []
var constraintsToActivate: [NSLayoutConstraint] = []
let contentView = cell.contentView
guard let visualEffectView = contentView.subviews.single(where: { $0 is UIVisualEffectView }) as! UIVisualEffectView? else {
throw MenuAlignmentFixError(description: "Did not find exactly 1 visual effect view.")
}
guard let label = contentView.subviews.single(where: { $0 is UILabel }) as! UILabel? else {
throw MenuAlignmentFixError(description: "Did not find exactly 1 label.")
}
let constraints = contentView.constraints
let margin: CGFloat = 16
// This one is a bit more tricky than the contextual menu because there are extra subviews.
// So to check if the fix was already applied let’s only look for one of the constraints
// that would be added and not the ones found by mapping from the original constraints.
let desiredTrailingConstraint = visualEffectView.trailingAnchor.constraint(equalTo: label.trailingAnchor, constant: margin)
if constraints.containsConstraints([desiredTrailingConstraint]) {
// The fix was already applied.
return
}
// Find the constraint positioning the label on the leading side.
// This used the safeAreaLayoutGuide on iOS 13, presumably accidentally because this changed on iOS 14.
let labelLeadingAnchor: NSLayoutXAxisAnchor
if #available(iOS 14.0, *) {
labelLeadingAnchor = label.leadingAnchor
} else {
labelLeadingAnchor = label.safeAreaLayoutGuide.leadingAnchor
}
guard let oldLeadingConstraint = constraints.single(where: { $0.doesInvolveAnchor(labelLeadingAnchor) }) else {
throw MenuAlignmentFixError(description: "Did not find exactly 1 leading constraint.")
}
constraintsToDeactivate.append(oldLeadingConstraint)
constraintsToActivate.append(desiredTrailingConstraint)
// Find all constraints that involve the visual effect view’s trailing anchor.
let oldTrailingConstraints = constraints.filter { $0.doesInvolveAnchor(visualEffectView.trailingAnchor) }
guard oldTrailingConstraints.count == 2 else {
throw MenuAlignmentFixError(description: "Unexpected number of trailing constraints: \(oldTrailingConstraints).")
}
constraintsToDeactivate += oldTrailingConstraints
constraintsToActivate += oldTrailingConstraints.map { oldConstraint -> NSLayoutConstraint in
let imageView = (oldConstraint.firstItem as! UIView == visualEffectView ? oldConstraint.secondItem : oldConstraint.firstItem) as! UIView
return imageView.leadingAnchor.constraint(equalTo: visualEffectView.leadingAnchor, constant: margin)
}
// Find all constraints that involve the label’s trailing anchor.
let oldMiddleConstraints = constraints.filter { $0.doesInvolveAnchor(label.trailingAnchor) }
guard oldMiddleConstraints.count == 2 else {
throw MenuAlignmentFixError(description: "Unexpected number of middle constraints: \(oldMiddleConstraints).")
}
constraintsToDeactivate += oldMiddleConstraints
constraintsToActivate += oldMiddleConstraints.map { oldConstraint -> NSLayoutConstraint in
// Replace them with constraints involving the same views but with the leading and trailing anchors flipped.
// Use our margin constant to ensure the margins are balanced even though as of 13.4 UIKit uses 16 anyway.
NSLayoutConstraint(item: oldConstraint.firstItem!, attribute: oldConstraint.secondAttribute, relatedBy: .equal, toItem: oldConstraint.secondItem, attribute: oldConstraint.firstAttribute, multiplier: oldConstraint.multiplier, constant: oldConstraint.constant < 0 ? margin : -margin)
}
NSLayoutConstraint.deactivate(constraintsToDeactivate)
NSLayoutConstraint.activate(constraintsToActivate)
}
private extension NSLayoutConstraint {
// Whether the constraint was set up between the two given anchors in either direction.
func isBetweenAnchor<AnchorType>(_ anchor: NSLayoutAnchor<AnchorType>, andAnchor otherAnchor: NSLayoutAnchor<AnchorType>) -> Bool {
firstAnchor == anchor && secondAnchor == otherAnchor || firstAnchor == otherAnchor && secondAnchor == anchor
}
// Whether either of the constraint’s anchors is the specified anchor.
func doesInvolveAnchor<AnchorType>(_ anchor: NSLayoutAnchor<AnchorType>) -> Bool {
firstAnchor == anchor || secondAnchor == anchor
}
/// Whether two constraint are between the same items with the same properties.
/// NSLayoutConstraint does not implement equality like this so this is needed.
/// This does not support the first and second items being flipped.
/// It would need to handle the relation and constant being flipped in that case.
func isSameAs(_ other: NSLayoutConstraint) -> Bool {
return self.relation == other.relation
&& self.priority == other.priority
&& self.multiplier == other.multiplier
&& self.constant == other.constant
&& self.firstItem === other.firstItem
&& self.firstAttribute == other.firstAttribute
&& self.secondItem === other.secondItem
&& self.secondAttribute == other.secondAttribute
}
}
private extension Array where Element: NSLayoutConstraint {
/// Whether `otherConstraints` is subset of the receiver when the elements are compared using `isSameAs`.
/// Time cost scales with the size of each array.
func containsConstraints(_ otherConstraints: [NSLayoutConstraint]) -> Bool {
otherConstraints.allSatisfy { otherConstraint in
contains { constraint in
constraint.isSameAs(otherConstraint)
}
}
}
}
private extension Array {
/// If there is exactly one object matching the predicate it will be returned. Returns nil if there are no matching objects or multiple matching objects.
func single(where predicate: (Element) throws -> Bool) rethrows -> Element? {
let matching = try filter(predicate)
guard matching.count == 1 else {
return nil
}
return matching[0]
}
}
// Douglas Hill, March 2020
@import Foundation;
NS_ASSUME_NONNULL_BEGIN
/**
Swizzles a method with no parameters and no return value by adding in the passed in block to the method call.
This should be called once for each modification.
@param theClass The class on which the method should be swizzled.
@param selector The instance method of the class to swizzle.
@param blockToAdd The block that will be added in when the method is called. The original method implementation (which may call super) will run before this block.
@return Whether the swizzle was successfully applied.
*/
BOOL swizzleVoidVoidMethod(Class theClass, SEL selector, void (^blockToAdd)(__unsafe_unretained id _self));
NS_ASSUME_NONNULL_END
// Douglas Hill, March 2020
#import "Swizzling.h"
@import ObjectiveC;
NS_ASSUME_NONNULL_BEGIN
#define let __auto_type const
BOOL swizzleVoidVoidMethod(Class classToSwizzle, SEL selector, void (^updateBlock)(__unsafe_unretained id _self)) {
let method = class_getInstanceMethod(classToSwizzle, selector);
// Bail if the method does not exist for this class or one of its parents.
if (method == nil) {
NSLog(@"Method %@ doesn’t exist on %@.", NSStringFromSelector(selector), classToSwizzle);
return NO;
}
// In case the method is only implemented by a superclass, add an implementation that just calls super.
// This won’t do anything if the target class already has the method.
let types = method_getTypeEncoding(method);
class_addMethod(classToSwizzle, selector, imp_implementationWithBlock(^(__unsafe_unretained id _self) {
struct objc_super _super = {_self, [classToSwizzle superclass]};
return ((id(*)(struct objc_super *, SEL))objc_msgSendSuper)(&_super, selector);
}), types);
// Swizzle the method to first call the original implementation and then call the custom block.
__block IMP originalImp = class_replaceMethod(classToSwizzle, selector, imp_implementationWithBlock(^(__unsafe_unretained id _self) {
((void (*)(id, SEL))originalImp)(_self, selector);
updateBlock(_self);
}), types);
return originalImp != NULL;
}
NS_ASSUME_NONNULL_END
@comiclandapp
Copy link

Hi, Doug. I'm trying out this class but it's bailing out on trying to find contextMenuActionViewClass. XCode version 13.3.1 (13E500a). Any ideas? TIA

    // Look up the classes.

    let contextMenuActionViewClassName = "_UIContextMenuActionView"
    guard let contextMenuActionViewClass = NSClassFromString(contextMenuActionViewClassName) else {
        throw MenuAlignmentFixError(description: "Could not look up \(contextMenuActionViewClassName).")
    }

@douglashill
Copy link
Author

This has been tested on iOS 13 and 14 and doesn’t yet work with iOS 15. On iOS 15, classes have been renamed but also the view hierarchy has changed, and so far I didn’t manager to make it work in all cases.

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