Thanks to visit codestin.com
Credit goes to github.com

Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Add support for iOS UndoManager #31415

Merged
merged 21 commits into from
May 11, 2022

Conversation

fbcouch
Copy link
Contributor

@fbcouch fbcouch commented Feb 11, 2022

This PR is modifies the engine to allow the framework to interact with the iOS UndoManager. That should enable 3-finger/shake gestures to undo/redo text editing and enable undo/redo from the iPad keyboard.

Related to flutter/flutter#34749 and flutter/flutter#77614

I haven't had a chance to test this out – still waiting for the engine to compile, but wanted to plant the flag on this and possibly feedback on the approach.

Framework PR: flutter/flutter#98294

Pre-launch Checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • I read the Tree Hygiene wiki page, which explains my responsibilities.
  • I read and followed the Flutter Style Guide and the C++, Objective-C, Java style guides.
  • I listed at least one issue that this PR fixes in the description above.
  • I added new tests to check the change I am making or feature I am adding, or Hixie said the PR is test-exempt. See testing the engine for instructions on
    writing and running engine tests.
  • I updated/added relevant documentation (doc comments with ///).
  • I signed the CLA.
  • All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel on Discord.

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for jumping on this, I was hoping we could support the iOS undo/redo gestures.

Is there any other benefit to using the native NSUndoManager versus detecting the gestures in the framework?

@fbcouch
Copy link
Contributor Author

fbcouch commented Feb 14, 2022

@justinmc I think using the NSUndoManager will actually allow us to support the 3-finger gestures, iPad keyboard, and voice control (flutter/flutter#77614) – I just tested that out and it seems to work properly. In the unlikely event that iOS adds another way to undo, I think having integrated with the NSUndoManager should support that as well.

@fbcouch
Copy link
Contributor Author

fbcouch commented Feb 14, 2022

Demo on iPad

@chunhtai chunhtai self-requested a review February 15, 2022 18:00
@justinmc
Copy link
Contributor

Ah nice I'm on board with that, it makes sense to do it this way instead of listening for the 3 finger swipe in the framework then. Let me know when you want me to review.

@fbcouch fbcouch marked this pull request as ready for review February 16, 2022 17:19
@fbcouch
Copy link
Contributor Author

fbcouch commented Feb 16, 2022

@justinmc Sounds good. I think this should be ready for review. Thanks!

@@ -950,6 +953,14 @@ - (void)flutterTextInputView:(FlutterTextInputView*)textInputView
arguments:@[ @(client) ]];
}

#pragma mark - Undo Manager Delegate

- (void)flutterTextInputPlugin:(FlutterTextInputPlugin*)textInputPlugin
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is textInputPlugin needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, but my understanding from creating the Scribble delegate is that it's best practice to pass the plugin in as the first argument to delegate methods (see https://github.com/flutter/engine/blob/main/shell/platform/darwin/ios/framework/Source/FlutterIndirectScribbleDelegate.h and https://github.com/flutter/engine/blob/main/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm#L919)

@@ -1831,6 +1834,7 @@ - (void)insertText:(NSString*)text {
[copiedRects release];
_selectionAffinity = _kTextAffinityDownstream;
[self replaceRange:_selectedTextRange withText:text];
[self ensureUndoEnabled];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we only call this when insertText or delete text is called?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe insert/delete are the only actions that should enable the undo buttons.

There is a strange issue with the undoManager where the first call to - registerUndoWithTarget doesn't actually enable the undo button. To work around this, this method calls - registerUndoWithTarget, then - removeAllActionsWithTarget to remove that placeholder. After that, calling - registerUndoWithTarget again will correctly enable the undo button.

Copy link
Contributor

@LongCatIsLooong LongCatIsLooong Mar 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The framework could also perform actions on behalf of the user that are potentially undo/redo-able?

}

- (void)setUndoManager:(NSUndoManager*)undoManager {
self.undoManagerOverride = undoManager;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why caches this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So that I can set a mock object for tests – maybe there's a better way if I could mock the FlutterTextInput itself instead?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anybody know if there's a better way to test this?

If not, maybe consider leaving a comment that this is done for testing.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe just stub the method? https://ocmock.org/reference/#stubing-methods

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anyhow you don't need to expose this as a public method. A private category in the test should do it.

@chinmaygarde
Copy link
Member

Any updates to @chunhtai queries?

@fbcouch
Copy link
Contributor Author

fbcouch commented Mar 3, 2022

Ah – sorry, I was moving last week and been catching up this week (and reworking the flutter side of this). I can go ahead and respond though, I think the responses will still be relevant when this gets reworked a bit.

@fbcouch fbcouch marked this pull request as draft March 3, 2022 22:41
@fbcouch fbcouch marked this pull request as ready for review March 9, 2022 19:14
@fbcouch
Copy link
Contributor Author

fbcouch commented Mar 9, 2022

All right, I split out the UndoManager interactions into their own channel and plugin, so I think this is ready for a review now

@justinmc justinmc requested a review from LongCatIsLooong March 9, 2022 22:49
@chinmaygarde
Copy link
Member

@LongCatIsLooong Ping on review?

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left some comments on the framework PR but assuming everyone is ok with that overall approach, this engine PR LGTM 👍

}

- (void)setUndoManager:(NSUndoManager*)undoManager {
self.undoManagerOverride = undoManager;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anybody know if there's a better way to test this?

If not, maybe consider leaving a comment that this is done for testing.

[self.undoManager beginUndoGrouping];
[self.undoManager registerUndoWithTarget:self
handler:^(id target) {
// register undo with opposite direction
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Capital letter and period on these comments here and below.

}

- (void)setUndoManager:(NSUndoManager*)undoManager {
self.undoManagerOverride = undoManager;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe just stub the method? https://ocmock.org/reference/#stubing-methods

}

- (void)setUndoManager:(NSUndoManager*)undoManager {
self.undoManagerOverride = undoManager;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anyhow you don't need to expose this as a public method. A private category in the test should do it.


- (void)dealloc {
[self resetUndoManager];
if (_undoManager != nil) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: just [_undoManager release] should be fine.

@@ -1831,6 +1834,7 @@ - (void)insertText:(NSString*)text {
[copiedRects release];
_selectionAffinity = _kTextAffinityDownstream;
[self replaceRange:_selectedTextRange withText:text];
[self ensureUndoEnabled];
Copy link
Contributor

@LongCatIsLooong LongCatIsLooong Mar 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The framework could also perform actions on behalf of the user that are potentially undo/redo-able?


- (void)ensureUndoEnabled API_AVAILABLE(ios(9.0)) {
if (![self.undoManager canUndo]) {
self.undoManager.groupsByEvent = NO;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is setting this to NO and setting it back necessary? Shouldn't beginUndoGrouping be enough?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can take another look at this – I tried a lot of different things to get the undo/redo buttons on the keyboard to show up the first time...this is where it ended up, but that doesn't mean it was all necessary.

Taken with your comment on line 1837, I wonder if this might make more sense in the UndoManagerPlugin – we might be able to make two calls across the channel, one to ensure undo is enabled, then set the state...that would keep this from being too tightly coupled to text input.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I think here, it isn't necessary – in the FlutterUndoManagerPlugin, though, it does seem to be. I suspect it has something to do with the fact that we're potentially registering multiple undos in the same event loop, and without groupsByEvent off, they still get grouped together by iOS.

I fiddled with trying to move the ensureUndoEnabled stuff into FlutterUndoManagerPlugin, but couldn't get it to actually work correctly. I'm not sure why, but I think doing it within the same event loop as a key press might be the secret sauce there. The good news is, I think the only thing that's needed for is to get iOS to correctly update the UI of the iPad keyboard on the first keypress. After that, things work as expected. For non-text input use cases, the keyboard wouldn't be up anyway, so that should be less of a concern.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah interesting thanks for looking into this! Since we're using a dedicated NSUndoManager in the plugin, would it work if we just turn groupsByEvent off at the beginning once and for all since we'll be manually grouping the events then?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we aren't actually using a dedicated NSUndoManager (except in tests), we're just grabbing the one from the FlutterViewController.

I do wonder, though, if some of the weirdness that requires the ensureUndoEnabled has to do with which NSUndoManager is active at the time – I did a bit of reading about it, and if I understand correctly, each NSResponder can have it's own UndoManager, and iOS walks up the responder chain to get the "active" undoManager.

I'm wondering if we should have our own FlutterUndoManager or override the FlutterTextInputView to return the one from the FlutterViewController or something like that. I ran out of time today before I could play around with that idea, but I'll try to get to it soon.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@LongCatIsLooong Just wanted to check in to see if you have any thoughts on this

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked into this, it looks like -[TUISystemInputAssistantView setNeedsValidation] triggers the keyboard update. Calling [self.inputDelegate didChangeSelection: self] should trigger that.

Copy link
Contributor

@LongCatIsLooong LongCatIsLooong Apr 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(if you have access to the input view that is). Or [firstResponder reloadInputViews] if you have access to the current first responder. But I'm not sure if that's going to affect market text.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the beginUndoGrouping pair still needed? It sounded like it was a workaround to trigger a keyboard update?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the grouping is still needed – the workaround for keyboard state was in the TextInputPlugin where I was registering and immediately removing an undo group. That workaround is replaced by the UITextInputAssistantItem business in FlutterUndoManagerPlugin. Calling selectionDidChange on the inputDelegate seems to be an adequate replacement, though, and seems at least a little more correct than removing and re-adding the input bar items.

@fbcouch fbcouch requested a review from LongCatIsLooong March 31, 2022 15:00
@chinmaygarde
Copy link
Member

@LongCatIsLooong Another review please?

Copy link
Contributor

@LongCatIsLooong LongCatIsLooong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work! Just a few questions to make sure I'm understanding the code correctly.

[self.undoManager undo];
}

- (void)setUndoState:(NSDictionary*)dictionary API_AVAILABLE(ios(9.0)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More of a framework question, but when should the framework update the undo state and how does it decide the value of canUndo/canRedo?

If we are in a text field that both operations are available then a new route pops up, I assume setUndoState needs to be called?

A different scenario, if we are in an editable text field that has an inline image widget, if the image is focused, has canUndo == false and canRedo == true, but for the text field both types of operations are available, is there a way to propagate the canUndo query up the focus chain to tell the system the undo operation is actually available?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, that probably makes sense on the framework side.

I suppose the question is, what should happen in that case? I'll readily admit I'm not super familiar with the intricacies of how undo works...if the image is focused, is the expected behavior that undo would be available? My naive assumption was that it should only reflect the undo state of whatever has focus currently.

I'm sure we can find a way to propagate that up, perhaps either re-using the focus tree or building a separate undo one.

BOOL canUndo = [dictionary[kCanUndo] boolValue];
BOOL canRedo = [dictionary[kCanRedo] boolValue];

[self resetUndoManager];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the plugin need to know how deep the undo stack is and how deep the redo stack is, so say after 3 consecutive left 3-finger swipes the undo button on the virtual keyboard becomes disabled? Or the framework is supposed to send another pair of booleans to update the canUndo and canRedo state?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, the latter – the framework is responsible for telling the engine whether undo and redo are available.

@zanderso
Copy link
Member

From PR triage: @LongCatIsLooong does this still look good to you? Also it looks like this PR needs to be rebased.

@LongCatIsLooong
Copy link
Contributor

Sorry still doing some tests. Need a little bit more time.

Comment on lines 32 to 35
id engine;
FlutterUndoManagerPluginForTest* undoManagerPlugin;
FlutterViewController* viewController;
NSUndoManager* undoManager;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nits: Use properties instead

@class FlutterUndoManagerPlugin;

@protocol FlutterUndoManagerDelegate <NSObject>
- (void)flutterUndoManagerPlugin:(FlutterUndoManagerPlugin*)undoManagerPlugin
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nits: a better Objective-C style naming would be:

- (void)flutterUndoManagerPlugin:(FlutterUndoManagerPlugin*)undoManagerPlugin
   handleUndoWithDirection:(FlutterUndoRedoDirection)direction;

}

- (void)resetUndoManager API_AVAILABLE(ios(9.0)) {
[self.undoManager removeAllActionsWithTarget:self];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nits:
I recommend against using dot notation for method invocations. Although It is legal in Objective-C, it decrease the readability of the code.
Dot is usually used to access properties in Objective-C, this confuses code readers as there's no property named undoManager.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha. I think I used to have a property for it (to allow overriding it in tests), so this was just left over from that. Thanks.

@fbcouch fbcouch force-pushed the feature/undo-redo-gestures branch from 854df54 to df791ee Compare May 5, 2022 20:16
@skia-gold
Copy link

Gold has detected about 229 new digest(s) on patchset 23.
View them at https://flutter-engine-gold.skia.org/cl/github/31415

fbcouch added 3 commits May 5, 2022 16:17
* Use properties in tests
* Use objc-style method invocation
* Rename delegate method to match objc convention
* Wrap setUndoState in groupsByEvent pair, rather than each method
* Use slightly more straightforward workaround for iPadOS keyboard update issue
@LongCatIsLooong
Copy link
Contributor

The approach LGTM. One last question: would it work better/worse, if we use our own NSUndoManager and override canUndo and canRedo to reflect the value sent by the framework?

@fbcouch
Copy link
Contributor Author

fbcouch commented May 11, 2022

That's a good question. I actually started down that road at one point while trying to get away from having that workaround in the TextInputPlugin. It's been a little while now, so I don't remember if there was an issue with it beyond just not replacing the workaround. Conceptually, I think it should work, and it would make the plugin code here a little less strange.

@LongCatIsLooong
Copy link
Contributor

Would you like to try that or should we merge the pull request as it is? For UITextField there's a dedicated NSUndoManager subclass and you won't be able to undo/redo edits made to other UI components when a UITextField is the first responder. But I'm not sure what makes the most sense in an add-to-app scenario.

@fbcouch
Copy link
Contributor Author

fbcouch commented May 11, 2022

I'm good with merging it as is, just in case using our own subclass messes something up with the UITextField's NSUndoManager subclass.

@LongCatIsLooong
Copy link
Contributor

We're not using UITextFields so that shouldn't be a problem.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
platform-ios waiting for tree to go green This PR is approved and tested, but waiting for the tree to be green to land.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants