Skip to content

Commit

Permalink
Provide support for context menus (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
sjchmiela authored Sep 2, 2021
1 parent a1036e0 commit 753d660
Show file tree
Hide file tree
Showing 13 changed files with 184 additions and 1 deletion.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ The changelog for `IGListKit`. Also see the [releases](https://github.com/instag

### Enhancements

- Added support for iOS 13 Context Menus with `contextMenuConfigurationForItemAt` method. [Jérôme B.](https://github.com/jjbourdev) [(#1430)](https://github.com/Instagram/IGListKit/pull/1430).

- Added `shouldSelectItemAtIndex:` to `IGListSectionController` . [dirtmelon](https://github.com/dirtmelon)

- Added [Mac Catalyst](https://developer.apple.com/mac-catalyst/) support. [Petro Rovenskyy](https://github.com/3a4oT/)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,26 @@ final class MonthSectionController: ListBindingSectionController<ListDiffable>,

func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, didUnhighlightItemAt index: Int, viewModel: Any) {}

@available(iOS 13.0, *)
func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, contextMenuConfigurationForItemAt index: Int, point: CGPoint, viewModel: Any) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
// Create an action for sharing
let share = UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up")) { _ in
// Show share sheet
}

// Create an action for copy
let rename = UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in
// Perform copy
}

// Create an action for delete with destructive attributes (highligh in red)
let delete = UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
// Perform delete
}

// Create a UIMenu with all the actions as children
return UIMenu(title: "", children: [share, rename, delete])
}
}
}
34 changes: 34 additions & 0 deletions Source/IGListKit/IGListSectionController.h
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,40 @@ NS_SWIFT_NAME(ListSectionController)
*/
- (void)didUnhighlightItemAtIndex:(NSInteger)index;

/**
Tells the section controller that the cell has requested a menu configuration.
@param index The index of the cell that requested the menu.
@param point The point of the tap on the cell.
@return An object that conforms to `UIContextMenuConfiguration`
@note The default implementation does nothing. **Calling super is not required.**
*/
- (UIContextMenuConfiguration * _Nullable)contextMenuConfigurationForItemAtIndex:(NSInteger)index point:(CGPoint)point API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos);

/**
Tells the section controller that the cell has requested a preview for context menu highlight.
@param configuration Context menu configuration.
@return An object that conforms to `UITargetedPreview`
@note The default implementation does nothing. **Calling super is not required.**
*/
- (UITargetedPreview * _Nullable)previewForHighlightingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos);

/**
Tells the section controller that the cell has requested a preview for context menu dismiss.
@param configuration Context menu configuration.
@return An object that conforms to `UITargetedPreview`
@note The default implementation does nothing. **Calling super is not required.**
*/
- (UITargetedPreview * _Nullable)previewForDismissingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos);

/**
Identifies whether an object can be moved through interactive reordering.
Expand Down
12 changes: 12 additions & 0 deletions Source/IGListKit/IGListSectionController.m
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,18 @@ - (void)didHighlightItemAtIndex:(NSInteger)index {}

- (void)didUnhighlightItemAtIndex:(NSInteger)index {}

- (UIContextMenuConfiguration * _Nullable)contextMenuConfigurationForItemAtIndex:(NSInteger)index point:(CGPoint)point {
return nil;
}

- (UITargetedPreview * _Nullable)previewForHighlightingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration {
return nil;
}

- (UITargetedPreview * _Nullable)previewForDismissingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration {
return nil;
}

- (BOOL)canMoveItemAtIndex:(NSInteger)index {
return NO;
}
Expand Down
47 changes: 47 additions & 0 deletions Source/IGListKit/Internal/IGListAdapter+UICollectionView.m
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@

#import "IGListAdapterInternal.h"

@interface IGListAdapter ()

@property (nonatomic, strong) NSMapTable *configurationToSectionController;

@end

@implementation IGListAdapter (UICollectionView)

#pragma mark - UICollectionViewDataSource
Expand Down Expand Up @@ -269,6 +275,47 @@ - (void)collectionView:(UICollectionView *)collectionView didUnhighlightItemAtIn
[sectionController didUnhighlightItemAtIndex:indexPath.item];
}

#if !TARGET_OS_TV
- (UIContextMenuConfiguration *)collectionView:(UICollectionView *)collectionView contextMenuConfigurationForItemAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point API_AVAILABLE(ios(13.0)) {
// forward this method to the delegate b/c this implementation will steal the message from the proxy
id<UICollectionViewDelegate> collectionViewDelegate = self.collectionViewDelegate;
if ([collectionViewDelegate respondsToSelector:@selector(collectionView:contextMenuConfigurationForItemAtIndexPath:point:)]) {
return [collectionViewDelegate collectionView:collectionView contextMenuConfigurationForItemAtIndexPath:indexPath point:point];
}

IGListSectionController * sectionController = [self sectionControllerForSection:indexPath.section];

UIContextMenuConfiguration *configuration = [sectionController contextMenuConfigurationForItemAtIndex:indexPath.item point:point];
if (configuration) {
if (!self.configurationToSectionController) {
self.configurationToSectionController = [NSMapTable weakToWeakObjectsMapTable];
}
[self.configurationToSectionController setObject:configuration forKey:sectionController];
}
return nil;
}

- (UITargetedPreview *)collectionView:(UICollectionView *)collectionView previewForHighlightingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration API_AVAILABLE(ios(13.0)){
id<UICollectionViewDelegate> collectionViewDelegate = self.collectionViewDelegate;
if ([collectionViewDelegate respondsToSelector:@selector(collectionView:previewForHighlightingContextMenuWithConfiguration:)]) {
return [collectionViewDelegate collectionView:collectionView previewForHighlightingContextMenuWithConfiguration:configuration];
}

IGListSectionController * sectionController = [self.configurationToSectionController objectForKey:configuration];
return [sectionController previewForHighlightingContextMenuWithConfiguration:configuration];
}

- (UITargetedPreview *)collectionView:(UICollectionView *)collectionView previewForDismissingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration API_AVAILABLE(ios(13.0)){
id<UICollectionViewDelegate> collectionViewDelegate = self.collectionViewDelegate;
if ([collectionViewDelegate respondsToSelector:@selector(collectionView:previewForDismissingContextMenuWithConfiguration:)]) {
return [collectionViewDelegate collectionView:collectionView previewForDismissingContextMenuWithConfiguration:configuration];
}

IGListSectionController * sectionController = [self.configurationToSectionController objectForKey:configuration];
return [sectionController previewForDismissingContextMenuWithConfiguration:configuration];
}
#endif

#pragma mark - UICollectionViewDelegateFlowLayout

- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
Expand Down
3 changes: 3 additions & 0 deletions Source/IGListKit/Internal/IGListAdapterProxy.m
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ static BOOL isInterceptedSelector(SEL sel) {
sel == @selector(collectionView:didDeselectItemAtIndexPath:) ||
sel == @selector(collectionView:didHighlightItemAtIndexPath:) ||
sel == @selector(collectionView:didUnhighlightItemAtIndexPath:) ||
sel == @selector(collectionView:contextMenuConfigurationForItemAtIndexPath:point:) ||
sel == @selector(collectionView:previewForHighlightingContextMenuWithConfiguration:) ||
sel == @selector(collectionView:previewForDismissingContextMenuWithConfiguration:) ||
// UICollectionViewDelegateFlowLayout
sel == @selector(collectionView:layout:sizeForItemAtIndexPath:) ||
sel == @selector(collectionView:layout:insetForSectionAtIndex:) ||
Expand Down
36 changes: 35 additions & 1 deletion Tests/IGListAdapterTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -1339,7 +1339,7 @@ - (void)test_whenUnhighlightingCell_thatCollectionViewDelegateReceivesMethod {
[mockDelegate verify];
}

- (void)test_whenUnlighlightingCell_thatSectionControllerReceivesMethod {
- (void)test_whenUnhighlightingCell_thatSectionControllerReceivesMethod {
self.dataSource.objects = @[@0, @1, @2];
[self.adapter reloadDataWithCompletion:nil];

Expand All @@ -1357,6 +1357,40 @@ - (void)test_whenUnlighlightingCell_thatSectionControllerReceivesMethod {
XCTAssertFalse(s2.wasUnhighlighted);
}

- (void)test_whenContextMenuAskedCell_thatCollectionViewDelegateReceivesMethod API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos) {
self.dataSource.objects = @[@0, @1, @2];
[self.adapter reloadDataWithCompletion:nil];

id mockDelegate = [OCMockObject mockForProtocol:@protocol(UICollectionViewDelegate)];
self.adapter.collectionViewDelegate = mockDelegate;

NSIndexPath *indexPath = [NSIndexPath indexPathForItem:0 inSection:0];
[[mockDelegate expect] collectionView:self.collectionView contextMenuConfigurationForItemAtIndexPath:indexPath point:CGPointZero];

// simulates the collectionview telling its delegate that it needs the context menu configuration
[self.adapter collectionView:self.collectionView contextMenuConfigurationForItemAtIndexPath:indexPath point:CGPointZero];

[mockDelegate verify];
}

- (void)test_whenContextMenuAskedCell_thatSectionControllerReceivesMethod API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos) {
self.dataSource.objects = @[@0, @1, @2];
[self.adapter reloadDataWithCompletion:nil];

NSIndexPath *indexPath = [NSIndexPath indexPathForItem:0 inSection:0];

// simulates the collectionview telling its delegate that it needs the context menu configuration
[self.adapter collectionView:self.collectionView contextMenuConfigurationForItemAtIndexPath:indexPath point:CGPointZero];

IGListTestSection *s0 = [self.adapter sectionControllerForObject:@0];
IGListTestSection *s1 = [self.adapter sectionControllerForObject:@1];
IGListTestSection *s2 = [self.adapter sectionControllerForObject:@2];

XCTAssertTrue(s0.requestedContextMenu);
XCTAssertFalse(s1.requestedContextMenu);
XCTAssertFalse(s2.requestedContextMenu);
}

- (void)test_whenDataSourceDoesntHandleObject_thatObjectIsDropped {
// IGListTestAdapterDataSource does not handle NSStrings
self.dataSource.objects = @[@1, @"dog", @2];
Expand Down
9 changes: 9 additions & 0 deletions Tests/IGListBindingSectionControllerTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,15 @@ - (void)test_whenUnhighlightingCell_thatCorrectViewModelUnhighlighted {
XCTAssertEqualObjects(section.unhighlightedViewModel, @"seven");
}

- (void)test_whenContextMenuAskedCell_thatCorrectViewModelRetrieved API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos) {
[self setupWithObjects:@[
[[IGTestDiffingObject alloc] initWithKey:@1 objects:@[@7, @"seven"]],
]];
[self.adapter collectionView:self.collectionView contextMenuConfigurationForItemAtIndexPath:[NSIndexPath indexPathForItem:1 inSection:0] point:CGPointZero];
IGTestDiffingSectionController *section = [self.adapter sectionControllerForObject:self.dataSource.objects.firstObject];
XCTAssertEqualObjects(section.contextMenuViewModel, @"seven");
}

- (void)test_whenDeselectingCell_withoutImplementation_thatNoOps {
[self setupWithObjects:@[
[[IGTestDiffingObject alloc] initWithKey:@1 objects:@[@7, @"seven"]],
Expand Down
1 change: 1 addition & 0 deletions Tests/Objects/IGListTestSection.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@
@property (nonatomic, assign) BOOL wasHighlighted;
@property (nonatomic, assign) BOOL wasUnhighlighted;
@property (nonatomic, assign) BOOL wasDisplayed;
@property (nonatomic, assign) BOOL requestedContextMenu;

@end
5 changes: 5 additions & 0 deletions Tests/Objects/IGListTestSection.m
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ - (void)didUnhighlightItemAtIndex:(NSInteger)index {
self.wasUnhighlighted = YES;
}

- (UIContextMenuConfiguration * _Nullable)contextMenuConfigurationForItemAtIndex:(NSInteger)index point:(CGPoint)point {
self.requestedContextMenu = YES;
return nil;
}

#pragma mark - IGListDisplayDelegate

- (void)listAdapter:(IGListAdapter *)listAdapter willDisplaySectionController:(IGListSectionController *)sectionController {
Expand Down
8 changes: 8 additions & 0 deletions Tests/Objects/IGTestBindingWithoutDeselectionDelegate.m
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,12 @@ - (void)sectionController:(nonnull IGListBindingSectionController *)sectionContr
viewModel:(nonnull id)viewModel {
}


- (UIContextMenuConfiguration * _Nullable)sectionController:(nonnull IGListBindingSectionController *)sectionController
contextMenuConfigurationForItemAtIndex:(NSInteger)index
point:(CGPoint)point
viewModel:(nonnull id)viewModel API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos) {
return nil;
}

@end
1 change: 1 addition & 0 deletions Tests/Objects/IGTestDiffingSectionController.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@
@property (nonatomic, strong) id deselectedViewModel;
@property (nonatomic, strong) id highlightedViewModel;
@property (nonatomic, strong) id unhighlightedViewModel;
@property (nonatomic, strong) id contextMenuViewModel;

@end
5 changes: 5 additions & 0 deletions Tests/Objects/IGTestDiffingSectionController.m
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,9 @@ - (void)sectionController:(IGListBindingSectionController *)sectionController di
self.unhighlightedViewModel = viewModel;
}

- (UIContextMenuConfiguration * _Nullable)sectionController:(IGListBindingSectionController *)sectionController contextMenuConfigurationForItemAtIndex:(NSInteger)index point:(CGPoint)point viewModel:(id)viewModel API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos) {
self.contextMenuViewModel = viewModel;
return nil;
}

@end

0 comments on commit 753d660

Please sign in to comment.