diff --git a/shell/platform/darwin/ios/BUILD.gn b/shell/platform/darwin/ios/BUILD.gn index 86a601dab52f9..4135ff9ecab4b 100644 --- a/shell/platform/darwin/ios/BUILD.gn +++ b/shell/platform/darwin/ios/BUILD.gn @@ -84,6 +84,9 @@ source_set("flutter_framework_source_arc") { "framework/Source/FlutterTextureRegistryRelay.mm", "framework/Source/FlutterUIPressProxy.h", "framework/Source/FlutterUIPressProxy.mm", + "framework/Source/FlutterUndoManagerDelegate.h", + "framework/Source/FlutterUndoManagerPlugin.h", + "framework/Source/FlutterUndoManagerPlugin.mm", "framework/Source/KeyCodeMap.g.mm", "framework/Source/KeyCodeMap_Internal.h", "framework/Source/UIViewController+FlutterScreenAndSceneIfLoaded.h", @@ -157,9 +160,6 @@ source_set("flutter_framework_source") { "framework/Source/FlutterPluginAppLifeCycleDelegate.mm", "framework/Source/FlutterSemanticsScrollView.h", "framework/Source/FlutterSemanticsScrollView.mm", - "framework/Source/FlutterUndoManagerDelegate.h", - "framework/Source/FlutterUndoManagerPlugin.h", - "framework/Source/FlutterUndoManagerPlugin.mm", "framework/Source/FlutterView.h", "framework/Source/FlutterView.mm", "framework/Source/FlutterViewController.mm", diff --git a/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm index 8fa12218b8633..f0fa9c8be5688 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm @@ -430,7 +430,6 @@ - (void)setViewController:(FlutterViewController*)viewController { [self maybeSetupPlatformViewChannels]; [self updateDisplays]; _textInputPlugin.get().viewController = viewController; - _undoManagerPlugin.get().viewController = viewController; if (viewController) { __block FlutterEngine* blockSelf = self; @@ -465,7 +464,6 @@ - (void)setFlutterViewControllerWillDeallocObserver:(id)observer { - (void)notifyViewControllerDeallocated { [[self lifecycleChannel] sendMessage:@"AppLifecycleState.detached"]; _textInputPlugin.get().viewController = nil; - _undoManagerPlugin.get().viewController = nil; if (!_allowHeadlessExecution) { [self destroyContext]; } else if (_shell) { @@ -1189,12 +1187,19 @@ - (void)flutterTextInputView:(FlutterTextInputView*)textInputView #pragma mark - Undo Manager Delegate -- (void)flutterUndoManagerPlugin:(FlutterUndoManagerPlugin*)undoManagerPlugin - handleUndoWithDirection:(FlutterUndoRedoDirection)direction { +- (void)handleUndoWithDirection:(FlutterUndoRedoDirection)direction { NSString* action = (direction == FlutterUndoRedoDirectionUndo) ? @"undo" : @"redo"; [_undoManagerChannel.get() invokeMethod:@"UndoManagerClient.handleUndo" arguments:@[ action ]]; } +- (UIView*)activeTextInputView { + return [[self textInputPlugin] textInputView]; +} + +- (NSUndoManager*)undoManager { + return self.viewController.undoManager; +} + #pragma mark - Screenshot Delegate - (flutter::Rasterizer::Screenshot)takeScreenshot:(flutter::Rasterizer::ScreenshotType)type diff --git a/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerDelegate.h b/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerDelegate.h index b81e7510d0114..bcfd609dd185c 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerDelegate.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerDelegate.h @@ -16,11 +16,29 @@ typedef NS_ENUM(NSInteger, FlutterUndoRedoDirection) { // NOLINTEND(readability-identifier-naming) }; -@class FlutterUndoManagerPlugin; - +/** + * Protocol for undo manager changes from the `FlutterUndoManagerPlugin`, typically a + * `FlutterEngine`. + */ @protocol FlutterUndoManagerDelegate -- (void)flutterUndoManagerPlugin:(FlutterUndoManagerPlugin*)undoManagerPlugin - handleUndoWithDirection:(FlutterUndoRedoDirection)direction; + +/** + * The `NSUndoManager` that should be managed by the `FlutterUndoManagerPlugin`. + * When the delegate is `FlutterEngine` this will be the `FlutterViewController`'s undo manager. + */ +@property(nonatomic, readonly, nullable) NSUndoManager* undoManager; + +/** + * Used to notify the active view when undo manager state (can redo/can undo) + * changes, in order to force keyboards to update undo/redo buttons. + */ +@property(nonatomic, readonly, nullable) UIView* activeTextInputView; + +/** + * Pass changes to the framework through the undo manager channel. + */ +- (void)handleUndoWithDirection:(FlutterUndoRedoDirection)direction; + @end NS_ASSUME_NONNULL_END diff --git a/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerPlugin.h b/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerPlugin.h index c2dac66f87d47..3a3271f7b360d 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerPlugin.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerPlugin.h @@ -7,15 +7,11 @@ #import -#import "flutter/fml/memory/weak_ptr.h" #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterChannels.h" -#import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerDelegate.h" @interface FlutterUndoManagerPlugin : NSObject -@property(nonatomic, assign) FlutterViewController* viewController; - - (instancetype)init NS_UNAVAILABLE; + (instancetype)new NS_UNAVAILABLE; diff --git a/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerPlugin.mm index b8f4b65f0f561..6eeb106c16ec3 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerPlugin.mm @@ -3,12 +3,6 @@ // found in the LICENSE file. #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerPlugin.h" -#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine_Internal.h" - -#import -#import - -#include "flutter/fml/logging.h" #pragma mark - UndoManager channel method names. static NSString* const kSetUndoStateMethod = @"UndoManager.setUndoState"; @@ -17,15 +11,16 @@ static NSString* const kCanUndo = @"canUndo"; static NSString* const kCanRedo = @"canRedo"; -@implementation FlutterUndoManagerPlugin { - id _undoManagerDelegate; -} +@interface FlutterUndoManagerPlugin () +@property(nonatomic, weak, readonly) id undoManagerDelegate; +@end + +@implementation FlutterUndoManagerPlugin - (instancetype)initWithDelegate:(id)undoManagerDelegate { self = [super init]; if (self) { - // `_undoManagerDelegate` is a weak reference because it should retain FlutterUndoManagerPlugin. _undoManagerDelegate = undoManagerDelegate; } @@ -33,8 +28,7 @@ - (instancetype)initWithDelegate:(id)undoManagerDele } - (void)dealloc { - [self resetUndoManager]; - [super dealloc]; + [_undoManagerDelegate.undoManager removeAllActionsWithTarget:self]; } - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { @@ -48,46 +42,43 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } } -- (NSUndoManager*)undoManager { - return _viewController.undoManager; -} - -- (void)resetUndoManager API_AVAILABLE(ios(9.0)) { - [[self undoManager] removeAllActionsWithTarget:self]; +- (void)resetUndoManager { + [self.undoManagerDelegate.undoManager removeAllActionsWithTarget:self]; } -- (void)registerUndoWithDirection:(FlutterUndoRedoDirection)direction API_AVAILABLE(ios(9.0)) { - [[self undoManager] beginUndoGrouping]; - [[self undoManager] registerUndoWithTarget:self - handler:^(id target) { - // Register undo with opposite direction. - FlutterUndoRedoDirection newDirection = - (direction == FlutterUndoRedoDirectionRedo) - ? FlutterUndoRedoDirectionUndo - : FlutterUndoRedoDirectionRedo; - [target registerUndoWithDirection:newDirection]; - // Invoke method on delegate. - [_undoManagerDelegate flutterUndoManagerPlugin:self - handleUndoWithDirection:direction]; - }]; - [[self undoManager] endUndoGrouping]; +- (void)registerUndoWithDirection:(FlutterUndoRedoDirection)direction { + NSUndoManager* undoManager = self.undoManagerDelegate.undoManager; + [undoManager beginUndoGrouping]; + [undoManager registerUndoWithTarget:self + handler:^(FlutterUndoManagerPlugin* target) { + // Register undo with opposite direction. + FlutterUndoRedoDirection newDirection = + (direction == FlutterUndoRedoDirectionRedo) + ? FlutterUndoRedoDirectionUndo + : FlutterUndoRedoDirectionRedo; + [target registerUndoWithDirection:newDirection]; + // Invoke method on delegate. + [target.undoManagerDelegate handleUndoWithDirection:direction]; + }]; + [undoManager endUndoGrouping]; } -- (void)registerRedo API_AVAILABLE(ios(9.0)) { - [[self undoManager] beginUndoGrouping]; - [[self undoManager] - registerUndoWithTarget:self - handler:^(id target) { - // Register undo with opposite direction. - [target registerUndoWithDirection:FlutterUndoRedoDirectionRedo]; - }]; - [[self undoManager] endUndoGrouping]; - [[self undoManager] undo]; +- (void)registerRedo { + NSUndoManager* undoManager = self.undoManagerDelegate.undoManager; + [undoManager beginUndoGrouping]; + [undoManager registerUndoWithTarget:self + handler:^(id target) { + // Register undo with opposite direction. + [target registerUndoWithDirection:FlutterUndoRedoDirectionRedo]; + }]; + [undoManager endUndoGrouping]; + [undoManager undo]; } -- (void)setUndoState:(NSDictionary*)dictionary API_AVAILABLE(ios(9.0)) { - BOOL groupsByEvent = [self undoManager].groupsByEvent; - [self undoManager].groupsByEvent = NO; +- (void)setUndoState:(NSDictionary*)dictionary { + NSUndoManager* undoManager = self.undoManagerDelegate.undoManager; + BOOL groupsByEvent = undoManager.groupsByEvent; + undoManager.groupsByEvent = NO; BOOL canUndo = [dictionary[kCanUndo] boolValue]; BOOL canRedo = [dictionary[kCanRedo] boolValue]; @@ -99,16 +90,15 @@ - (void)setUndoState:(NSDictionary*)dictionary API_AVAILABLE(ios(9.0)) { if (canRedo) { [self registerRedo]; } - - if (_viewController.engine.textInputPlugin.textInputView != nil) { + UIView* textInputView = self.undoManagerDelegate.activeTextInputView; + if (textInputView != nil) { // This is needed to notify the iPadOS keyboard that it needs to update the // state of the UIBarButtons. Otherwise, the state changes to NSUndoManager // will not show up until the next keystroke (or other trigger). - UITextInputAssistantItem* assistantItem = - _viewController.engine.textInputPlugin.textInputView.inputAssistantItem; + UITextInputAssistantItem* assistantItem = textInputView.inputAssistantItem; assistantItem.leadingBarButtonGroups = assistantItem.leadingBarButtonGroups; } - [self undoManager].groupsByEvent = groupsByEvent; + undoManager.groupsByEvent = groupsByEvent; } @end diff --git a/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerPluginTest.mm index c9edad986370e..62dcb8089a237 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerPluginTest.mm @@ -8,54 +8,77 @@ #import #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h" -#import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterEngine.h" -#import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h" -#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h" FLUTTER_ASSERT_ARC -@interface FlutterEngine () -- (nonnull FlutterUndoManagerPlugin*)undoManagerPlugin; -- (nonnull FlutterTextInputPlugin*)textInputPlugin; +/// OCMock does not allow mocking both class and protocol. Use this to mock the methods used on +/// `UIView*` in the plugin. +@interface TextInputViewTest : NSObject + +@property(nonatomic, weak) id inputDelegate; +@property(nonatomic, readonly) UITextInputAssistantItem* inputAssistantItem; + +@end + +@implementation TextInputViewTest @end -@interface FlutterUndoManagerPluginForTest : FlutterUndoManagerPlugin -@property(nonatomic, assign) NSUndoManager* undoManager; +@interface FakeFlutterUndoManagerDelegate : NSObject + +@property(readonly) NSUInteger undoCount; +@property(readonly) NSUInteger redoCount; + +- (instancetype)initWithUndoManager:(NSUndoManager*)undoManager + activeTextInputView:(TextInputViewTest*)activeTextInputView; + @end -@implementation FlutterUndoManagerPluginForTest { +@implementation FakeFlutterUndoManagerDelegate + +@synthesize undoManager = _undoManager; +@synthesize activeTextInputView = _activeTextInputView; + +- (instancetype)initWithUndoManager:(NSUndoManager*)undoManager + activeTextInputView:(UIView*)activeTextInputView { + self = [super init]; + if (self) { + _undoManager = undoManager; + _activeTextInputView = activeTextInputView; + } + return self; } + +- (void)handleUndoWithDirection:(FlutterUndoRedoDirection)direction { + if (direction == FlutterUndoRedoDirectionUndo) { + _undoCount++; + } else { + _redoCount++; + } +} + @end @interface FlutterUndoManagerPluginTest : XCTestCase -@property(nonatomic, strong) id engine; -@property(nonatomic, strong) FlutterUndoManagerPluginForTest* undoManagerPlugin; -@property(nonatomic, strong) FlutterViewController* viewController; -@property(nonatomic, strong) NSUndoManager* undoManager; +@property(nonatomic) FakeFlutterUndoManagerDelegate* undoManagerDelegate; +@property(nonatomic) FlutterUndoManagerPlugin* undoManagerPlugin; +@property(nonatomic) TextInputViewTest* activeTextInputView; +@property(nonatomic) NSUndoManager* undoManager; @end -@implementation FlutterUndoManagerPluginTest { -} +@implementation FlutterUndoManagerPluginTest - (void)setUp { [super setUp]; - self.engine = OCMClassMock([FlutterEngine class]); - - self.undoManagerPlugin = [[FlutterUndoManagerPluginForTest alloc] initWithDelegate:self.engine]; - - self.viewController = [[FlutterViewController alloc] init]; - self.undoManagerPlugin.viewController = self.viewController; self.undoManager = OCMClassMock([NSUndoManager class]); - self.undoManagerPlugin.undoManager = self.undoManager; -} + self.activeTextInputView = OCMClassMock([TextInputViewTest class]); + + self.undoManagerDelegate = + [[FakeFlutterUndoManagerDelegate alloc] initWithUndoManager:self.undoManager + activeTextInputView:self.activeTextInputView]; -- (void)tearDown { - [self.undoManager removeAllActionsWithTarget:self.undoManagerPlugin]; - self.engine = nil; - self.viewController = nil; - self.undoManager = nil; - [super tearDown]; + self.undoManagerPlugin = + [[FlutterUndoManagerPlugin alloc] initWithDelegate:self.undoManagerDelegate]; } - (void)testSetUndoState { @@ -74,18 +97,6 @@ - (void)testSetUndoState { .andDo(^(NSInvocation* invocation) { removeAllActionsCount++; }); - __block int delegateUndoCount = 0; - OCMStub([self.engine flutterUndoManagerPlugin:[OCMArg any] - handleUndoWithDirection:FlutterUndoRedoDirectionUndo]) - .andDo(^(NSInvocation* invocation) { - delegateUndoCount++; - }); - __block int delegateRedoCount = 0; - OCMStub([self.engine flutterUndoManagerPlugin:[OCMArg any] - handleUndoWithDirection:FlutterUndoRedoDirectionRedo]) - .andDo(^(NSInvocation* invocation) { - delegateRedoCount++; - }); __block int undoCount = 0; OCMStub([self.undoManager undo]).andDo(^(NSInvocation* invocation) { undoCount++; @@ -114,14 +125,14 @@ - (void)testSetUndoState { // Invoking the undo handler will invoke the handleUndo delegate method with "undo". undoHandler(self.undoManagerPlugin); - XCTAssertEqual(1, delegateUndoCount); - XCTAssertEqual(0, delegateRedoCount); + XCTAssertEqual(1UL, self.undoManagerDelegate.undoCount); + XCTAssertEqual(0UL, self.undoManagerDelegate.redoCount); XCTAssertEqual(2, registerUndoCount); // Invoking the redo handler will invoke the handleUndo delegate method with "redo". undoHandler(self.undoManagerPlugin); - XCTAssertEqual(1, delegateUndoCount); - XCTAssertEqual(1, delegateRedoCount); + XCTAssertEqual(1UL, self.undoManagerDelegate.undoCount); + XCTAssertEqual(1UL, self.undoManagerDelegate.redoCount); XCTAssertEqual(3, registerUndoCount); // If canRedo is true, an undo will be registered and undo will be called. @@ -137,22 +148,12 @@ - (void)testSetUndoState { // Invoking the redo handler will invoke the handleUndo delegate method with "redo". undoHandler(self.undoManagerPlugin); - XCTAssertEqual(1, delegateUndoCount); - XCTAssertEqual(2, delegateRedoCount); + XCTAssertEqual(1UL, self.undoManagerDelegate.undoCount); + XCTAssertEqual(2UL, self.undoManagerDelegate.redoCount); } - (void)testSetUndoStateDoesInteractWithInputDelegate { // Regression test for https://github.com/flutter/flutter/issues/133424 - FlutterViewController* viewController = OCMPartialMock(self.viewController); - self.undoManagerPlugin.viewController = self.viewController; - - FlutterTextInputPlugin* textInputPlugin = OCMClassMock([FlutterTextInputPlugin class]); - FlutterTextInputView* textInputView = OCMClassMock([FlutterTextInputView class]); - - OCMStub([viewController engine]).andReturn(self.engine); - OCMStub([self.engine textInputPlugin]).andReturn(textInputPlugin); - OCMStub([textInputPlugin textInputView]).andReturn(textInputView); - FlutterMethodCall* setUndoStateCall = [FlutterMethodCall methodCallWithMethodName:@"UndoManager.setUndoState" arguments:@{@"canUndo" : @NO, @"canRedo" : @NO}]; @@ -160,7 +161,7 @@ - (void)testSetUndoStateDoesInteractWithInputDelegate { result:^(id _Nullable result){ }]; - OCMVerify(never(), [textInputView inputDelegate]); + OCMVerify(never(), [self.activeTextInputView inputDelegate]); } @end