Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix UIResponder handling with view backing ASDisplayNode #789

Merged
merged 13 commits into from
Feb 23, 2018

Conversation

maicki
Copy link
Contributor

@maicki maicki commented Feb 5, 2018

What is the problem?

If certain UIResponder methods are overwritten in a ASDisplayNode subclass they are not considered. The only two methods that are currently properly forwarded are: canBecomeFirstResponder and canResignFirstResponder in _ASDisplayView.mm. Every other UIResponder method will never reach any _ASDisplayView. This especially is problem for ASEditableTextNode as we overwrite these methods in there and expect to be considered.

Example repo: https://github.com/maicki/TextureResponderChain

Tests

I added a test that show's this invalid behavior. You can find them under ASDisplayNodeTests.mm and ASTableViewTests.mm

Some more details

The way I actually stumbled upon the issue is that as soon as an ASEditableTextNode is a subnode of a ASCellNode as well as a first responder, calling reloadData on an ASTableNode will not reload the UITableView. UIKit will bail out within the process.
Internally UIKit's UITableView is trying to resign the first responder from the current first responder on an reloadData what is the textView of the ASEditableTextNode and afterwards expects no view in the UITableView hierarchy is the first responder anymore. That will not be case if UITableView is calling resignFirstResponder on the ASEditableTextNode.textView as we push only the first responder to the ASEditableTextNode.view. UITableView has an ivar called _firstResponderView where it determines if the first responder view is a view in the UITableView hierarchy. If this is ivar != null it will bail out if reloadData is called, before actually reloading the data (I verified that within Hopper).
And so if the UITableView is calling resignFirstResponder on the ASEditableTextNode.textView the _firstResponderView will just be ASEditableTextNode.view, but it should be nil. If we properly forward all responder methods _firstResponderView will be nil as the ASEditableTextNode.view will not become the first responder if the table view calls resignFirstResponder on the ASEditableTextNode.textView.

Current solution

// Called canBecomeFirstResponder on _ASDisplayView
-> [_ASDisplayView canBecomeFirstResponder]
  -> if subclass of _ASDisplayView overwrites canBecomeFirstResponder
      // Prevent an infinite loop in here if [super canBecomeFirstResponder] was called
      -> if subclass of ASDisplayNode overwrites canBecomeFirstResponder
        -> [ASDisplayNode canBecomeFirstResponder];
      -> else
        // Call through to views superclass as we expect super was called from the
        // _ASDisplayView subclass and node does not implement canBecomeFirstResponder
        -> [_ASDisplayView __canBecomeFirstResponder]
          -> [super canBecomeFirstResponder]
  -> else
      // Call through to the display node to give a chance to dive in
    -> [ASDisplayNode __canBecomeFirstResponder]

// Called canBecomeFirstResponder on ASDisplayNode
-> [ASDisplayNode+UIViewBridge canBecomeFirstResponder]
  // Just call through to our internal method
  -> [ASDisplayNode (self) __canBecomeFirstResponder]

// Called form _ASDisplayView and ASDisplayNode from ASDisplayNode+UIViewBridge
-> [ASDisplayNode __canBecomeFirstResponder]
  -> if view it is a subclass of _ASDisplayView (synchronous == TRUE)
    // If it's synchronous just call through to the view as we expect it's
    // a non _ASDisplayView subclass that will respond
    -> [_view canBecomeFirstResponder]
  -> else
      -> if subclass of _ASDisplayView overwrites canBecomeFirstResponder
        // If the subclass overwrites canBecomeFirstResponder just call through
        // to it as we expect it will handle it
        [_view canBecomeFirstResponder];
      -> else
        // Call through to _ASDisplayView to get it handled
        -> [_ASDisplayView __canBecomeFirstResponder]
          -> [super canBecomeFirstResponder]

@ghost
Copy link

ghost commented Feb 5, 2018

🚫 CI failed with log

}

if (checkFlag(Synchronous)
|| ASSubclassOverridesSelector([_ASDisplayView class], _viewClass, @selector(canBecomeFirstResponder))) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We may should catch all of them in the initialization phase like ASDisplayNodeMethodOverrides. That said at that point we may not know the actual view class so we only could do it after the view is created.

// We explicitly create the view in here as it's supposed to become a first responder
[self view];

if (checkFlag(Synchronous)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This should probably extracted to a macro as the only change is the selector

return [node canResignFirstResponder];
- (BOOL)becomeFirstResponder
{
ASDisplayNode *node = _asyncdisplaykit_node;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This parts also be extracted to a macro.

@timonus
Copy link
Contributor

timonus commented Feb 6, 2018

Thank you so much for fixing this Michael!

@appleguy
Copy link
Member

appleguy commented Feb 9, 2018

Amazing analysis and fix @maicki !

I never knew about this behavior, but it totally makes sense to avoid text fields being cleared by accidental app behaviors when a user is actively editing them.

@ghost
Copy link

ghost commented Feb 9, 2018

🚫 CI failed with log

@appleguy
Copy link
Member

@maicki thanks for the ping, I'll take a look soon! Thanks for the really awesome description, and analysis underlying it :).

Copy link
Member

@appleguy appleguy left a comment

Choose a reason for hiding this comment

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

Thanks especially for the tests! That significantly helps our future-looking confidence to maintain correctness of these details.

// There are certain cases we cannot handle and are not supported:
// 1. If the _view class is not a subclass of _ASDisplayView
if (checkFlag(Synchronous)) {
// 2. At least one UIResponder methods are overwritten in the node subclass
Copy link
Member

Choose a reason for hiding this comment

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

Any concerns with touchesBegan:, touchesMoved:, touchesEnded:, or touchesCancelled: ?

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 didn't look into them or saw any issues related to them.

#pragma mark UIResponder

#define HANDLE_RESPONDER_METHOD(__sel) \
if (checkFlag(Synchronous)) { \
Copy link
Member

Choose a reason for hiding this comment

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

This should probably assert main thread. At least -becomeFirstResponder might be reasonable for a developer to expect to be a UIViewBridge property and threadsafe to call, but this implementation triggers view creation.

Maybe worth even a special assertion in -becomeFirstResponder that tells developers to call that method on the main thread or in onDidLoad / -didLoad.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agree, I added a main thread assertion for all responder methods.


- (BOOL)__becomeFirstResponder
{
if (self.isLayerBacked || ![self canBecomeFirstResponder]) {
Copy link
Member

Choose a reason for hiding this comment

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

Check for isLayerBacked is probably redundant, and also grabs lock. Could check _view if a short circuit is needed, as this method is main thread only then lock isn't needed there.

Copy link
Contributor Author

@maicki maicki Feb 21, 2018

Choose a reason for hiding this comment

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

I think we can get rid of the isLayerBacked call at all. We check for the _view in canBecomeFirstResponder anyway that should cover that case.

@@ -828,6 +830,94 @@ - (void)nodeViewDidAddGestureRecognizer
_flags.viewEverHadAGestureRecognizerAttached = YES;
}

#pragma mark UIResponder
Copy link
Member

Choose a reason for hiding this comment

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

IMO this should go in another file, since ASDisplayNode.mm is quite large - it seems very appropriate for UIViewBridge or perhaps we could introduce another one.

Not a blocking change (feel free to land this if you're just eager to get it in!)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We are currently have the event handling touchesBegan: etc. in ASDisplayNode.mm, eventually we should move both of them out too in a follow up diff.

@@ -39,6 +39,14 @@ NS_ASSUME_NONNULL_BEGIN
- (void)__forwardTouchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)__forwardTouchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;

// These methods expose a way for ASDisplayNode responder methods to let the view call super responder methods
Copy link
Member

Choose a reason for hiding this comment

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

This first sentence isn't quite clear -- "for ASDisplayNode responder methods to let the view call super responder methods".

Clarify if possible, otherwise not exactly sure how to improve it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The sentence is kind of the same as the one for all of the forwarding touch events. In that case we should find a better way for both.


- (BOOL)canBecomeFirstResponder
{
HANDLE_RESPONDER_METHOD(canBecomeFirstResponder);
Copy link
Member

Choose a reason for hiding this comment

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

I think you could use cmd for each of these. They could also be made a macro for IMPLEMENT_RESPONDER_METHOD instead of HANDLE, and define the method as well as its contents.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Moved all into one macros and called it IMPLEMENT_RESPONDER_METHOD

#pragma mark UIResponder Handling

#define HANDLE_RESPONDER_METHOD(__sel) \
ASDisplayNode *node = _asyncdisplaykit_node; \
Copy link
Member

Choose a reason for hiding this comment

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

Can you keep the comment // Create a strong reference to weak ivar, from the original code? It would be easy to think this could be dropped and accessed directly on the ivar, but I assume the implication is that the ivar might change or become invalid.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added in back

// This methods are called from ASDisplayNode to let the view decide in what UIResponder state they are not overwritten
// by a ASDisplayNode subclass

- (BOOL)__canBecomeFirstResponder
Copy link
Member

Choose a reason for hiding this comment

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

Technically these could be macro-ized as well, generated in a pair with the above ones :). Optional of course.

Nits: 2 spaces instead of 4 on the returns. Also, "overridden" instead of "overwritten" in the comment.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also moved this into the macro and to 2 spaces.

}

// Note: this implicitly loads the view if it hasn't been loaded yet.
Copy link
Member

Choose a reason for hiding this comment

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

Seems like a good comment to keep.

Copy link
Contributor Author

@maicki maicki Feb 21, 2018

Choose a reason for hiding this comment

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

We had this kind of comment in __becomeFirstResponder now:

// Note: this implicitly loads the view if it hasn't been loaded yet.

I changed it to the old comment in there though. Let me know if I should also add it to this place back again.

- (BOOL)canBecomeFirstResponder { return YES; }
- (BOOL)becomeFirstResponder {
return [super becomeFirstResponder];

Copy link
Member

Choose a reason for hiding this comment

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

Extra return

@maicki
Copy link
Contributor Author

maicki commented Feb 22, 2018

@appleguy Thank you for reviewing. Can you take another look over it please. Thanks!

Copy link
Member

@Adlai-Holler Adlai-Holler left a comment

Choose a reason for hiding this comment

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

Damn impressive work Michael. I'm down to land.


// Check that numberOfRows in section 0 is 2
XCTAssertTrue([node numberOfRowsInSection:0] == 2, @"Number of rows in section 0 should be 2 after reload");
XCTAssertTrue([node.view numberOfRowsInSection:0] == 2, @"Number of rows in section 0 should be 2 after reload");
Copy link
Member

Choose a reason for hiding this comment

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

Nit: use XCTAssertEqual so that we can see the failing value if this fails on CI in the future.

@maicki maicki merged commit 236cdd7 into master Feb 23, 2018
@Adlai-Holler Adlai-Holler deleted the MSFixResponderMethods branch March 11, 2018 23:36
bernieperez pushed a commit to AtomTickets/Texture that referenced this pull request Apr 25, 2018
…p#789)

* Add failing tests

* Fix responder chain handling in Texture

* Add mores tests that horrible fail

* Add Changelog.md entry

* Some fixes

* Update logic

* Add tests that prevents infinite loops if a custom view is overwriting UIResponder methods

* Add macro to forward methods in ASDisplayNode

* Add macro for forwarding responder methods in _ASDisplayView

* Remove junk

* Address first comments

* Update _ASDisplayView to cache responder forwarding methods

* Use XCTAssertEqual
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants