-
Notifications
You must be signed in to change notification settings - Fork 6k
Add support for iOS UndoManager #31415
Add support for iOS UndoManager #31415
Conversation
dbfd62a
to
8c82076
Compare
There was a problem hiding this 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?
@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. |
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. |
@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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is textInputPlugin needed?
There was a problem hiding this comment.
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]; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why caches this?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
Any updates to @chunhtai queries? |
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. |
All right, I split out the UndoManager interactions into their own channel and plugin, so I think this is ready for a review now |
@LongCatIsLooong Ping on review? |
There was a problem hiding this 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; |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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]; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
@LongCatIsLooong Another review please? |
There was a problem hiding this 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)) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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]; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
From PR triage: @LongCatIsLooong does this still look good to you? Also it looks like this PR needs to be rebased. |
Sorry still doing some tests. Need a little bit more time. |
id engine; | ||
FlutterUndoManagerPluginForTest* undoManagerPlugin; | ||
FlutterViewController* viewController; | ||
NSUndoManager* undoManager; |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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]; |
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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.
…d when changing text (used with the flutter_undo plugin)
…into the framework to undo/redo appropriately
… think there might be some issue with which UndoManager is currently active in the responder chain
…h a workaround in FlutterUndoManagerPlugin to notify the UIAssistantBar that it needs to update it's button state
854df54
to
df791ee
Compare
Gold has detected about 229 new digest(s) on patchset 23. |
* 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
The approach LGTM. One last question: would it work better/worse, if we use our own |
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. |
Would you like to try that or should we merge the pull request as it is? For |
I'm good with merging it as is, just in case using our own subclass messes something up with the UITextField's NSUndoManager subclass. |
We're not using |
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
writing and running engine tests.
///
).If you need help, consider asking for advice on the #hackers-new channel on Discord.