How does focus update deferral work?

Hello. I'd like to improve my keyboard focus experience on iPad in one of the usage scenarios of my app - browsing through two UICollectionViews in a master-detail pattern.

I am able to navigate the master collection view with the arrow keys, and focus is indicated correctly; however, after selecting an item with the keyboard, which pushes the detail collection view on the same navigation stack, the focus indicator disappears and the following message appears in the console:

[UIFocus] Starting to defer focus updates.

followed by a symbolicated stack trace. At this point, the user has to tap one of the arrow keys so that the focus indicator reappears. In this case, if the user then exits the detail collection view, which reveals the master collection view, the item that was focused and selected on the master collection view will be indicated correctly. The console shows the following message:

[UIFocus] Disabling focus deferral.

followed once again by a symbolicated stack trace.

Interestingly, the focus indicator also disappears after a period of time if the user leaves the app open and does not interact with it. In that case, the same message about starting to defer focus updates will appear, and the subsequent stack trace will contain an __NSFireTimer frame.

My question would be - what are the criteria for focus deferral and how can I control its behavior?

Thank you. Please let me know if I can add any relevant details to this thread.

Accepted Reply

Focus deferral is a state in which the focus system keeps track of an item to focus on but it suppresses the update to that item. This means that nothing is focused. This happens whenever the system thinks that the user is not using focus or that the context the user was focusing on is no longer available.

In your case the stack trace indicates that the view or a superview of the focused item is disappearing from screen so focus starts deferring focus again because the context the user was focusing on got removed.

This is expected if you push on to the same navigation stack the user was interacting with as the focused cell goes away, however focus should then try and find a new view in that same stack. It sounds like for some reason the focus system is unable to do so, maybe because at the time it tries there are no focusable views? Are you dynamically loading content?

You might get more logs if you launch your app with the UIFocusLoggingEnabled=1 launch argument, specified in Xcode.

Replies

I incorrectly said in my previous post that during focus deferral I received symbolicated stack traces in the console - I actually meant the opposite. However, when testing on the simulator it seems that the traces are actually symbolicated:

2022-08-03 13:38:45.220746+0300 App[94677:24673225] [UIFocus] Starting to defer focus updates.
(
	0   UIKitCore                           0x000000011002ecfc -[UIFocusSystem _resetFocusDeferral] + 420
	1   UIKitCore                           0x0000000110031610 -[UIFocusSystem updateFocusIfNeeded] + 1164
	2   UIKitCore                           0x000000011003083c __44-[UIFocusSystem _focusEnvironmentDidAppear:]_block_invoke + 28
	3   UIKitCore                           0x000000011096c690 -[_UIAfterCACommitBlock run] + 64
	4   UIKitCore                           0x000000011096cac4 -[_UIAfterCACommitQueue flush] + 172
	5   libdispatch.dylib                   0x00000001038d85f4 _dispatch_call_block_and_release + 24
	6   libdispatch.dylib                   0x00000001038d9dbc _dispatch_client_callout + 16
	7   libdispatch.dylib                   0x00000001038ea814 _dispatch_main_queue_drain + 1316
	8   libdispatch.dylib                   0x00000001038ea2e0 _dispatch_main_queue_callback_4CF + 40
	9   CoreFoundation                      0x00000001803714d0 __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 12
	10  CoreFoundation                      0x000000018036bc60 __CFRunLoopRun + 1956
	11  CoreFoundation                      0x000000018036b0a4 CFRunLoopRunSpecific + 584
	12  GraphicsServices                    0x00000001892dcc8c GSEventRunModal + 160
	13  UIKitCore                           0x000000011052fe80 -[UIApplication _run] + 868
	14  UIKitCore                           0x0000000110533e28 UIApplicationMain + 124
	15  FontBrowseriOSTestApp               0x0000000102bb3ad8 main + 64
	16  dyld                                0x0000000102d6dfa0 start_sim + 20
	17  ???                                 0x0000000102e5d08c 0x0 + 4343582860
	18  ???                                 0x1805000000000000 0x0 + 1730789631793823744
)

Can anyone share some details about this focus deferral? Thanks.

Focus deferral is a state in which the focus system keeps track of an item to focus on but it suppresses the update to that item. This means that nothing is focused. This happens whenever the system thinks that the user is not using focus or that the context the user was focusing on is no longer available.

In your case the stack trace indicates that the view or a superview of the focused item is disappearing from screen so focus starts deferring focus again because the context the user was focusing on got removed.

This is expected if you push on to the same navigation stack the user was interacting with as the focused cell goes away, however focus should then try and find a new view in that same stack. It sounds like for some reason the focus system is unable to do so, maybe because at the time it tries there are no focusable views? Are you dynamically loading content?

You might get more logs if you launch your app with the UIFocusLoggingEnabled=1 launch argument, specified in Xcode.

Thank you for the detailed reply. I am indeed using -UIFocusLoggingEnabled YES for debugging this feature while working on it.

Are you dynamically loading content?

That is correct. I should have specified this from the start as, based on what I'm seeing online, it isn't a frequent use case.

Through trial and error I found a workaround to give the focus engine a valid view at all times: by using a root view with override var canBecomeFocused { true } for my VC, and requesting a focus update once I am reasonably certain there is at least one visible cell in the collection view (e.g. viewDidLayoutSubviews). Is there perhaps a better way to have the focus engine wait for a cell to appear and then move focus to it?

Even better, would there be a way to simply specify an IndexPath that should be prioritized for focusing whenever it becomes visible? It would help with another part of the overall keyboard focus feature that I'm working on.

UICollectionView and UITableView have delegate methods for the preferred focused index path that will be used. However if the focus system starts deferring, it waits for user interaction to act on that. I'd suggest to not do anything here. If you do not present content initially, the expected user experience is that focus shuts off again and only becomes active again when the user interacts with it.

I'd suggest to not do anything here. If you do not present content initially, the expected user experience is that focus shuts off again and only becomes active again when the user interacts with it.

Agreed, my intent was simply to customize the behavior of the focus engine very slightly in order to create a consistent experience; as the collection view is in fact populated in less than a second from showing its VC (this includes the animation from UICollectionViewDiffableDataSource.apply(_:animatingDifferences:completion:)), it is a bit jarring to sometimes have the focus moved to the correct item and sometimes not.

UICollectionView and UITableView have delegate methods for the preferred focused index path that will be used

Here I would like to suggest a clarification for the docs - collectionView(_:shouldUpdateFocusIn:) and indexPathForPreferredFocusedView(in:) only seem to be called for focus movements that occur after the UICollectionView first receives focus. This was not clear initially and was a pain to work around, as we need the custom behavior from those two methods even upon the first focus request since starting the app (e.g. to be able to focus the first item that is completely visible and does not intersect a sticky header after scrolling). This might have something to do with being in focus deferral mode, of course.

Thank you for your help on this topic and please feel free to challenge any incorrect statements I've made - at the moment I only have empirical knowledge on focus quirks.

Agreed, my intent was simply to customize the behavior of the focus engine very slightly in order to create a consistent experience

I get the point. Unfortunately there is currently no way to programmatically disable focus deferral. It would be great if you could file feedback to request this so we can track this properly internally.

indexPathForPreferredFocusedView(in:) only seem to be called for focus movements that occur after the UICollectionView first receives focus.

That sounds incorrect and if you can reproduce this in a sample, sending this over via feedback would be great. In fact it should be pretty much the other way around.

The delegate method should be called, even with focus deferral enabled. Although with focus deferral enabled the method will be called when focus runs the hidden update and not when the user starts interacting with it.

This method should only be skipped if selectionFollowsFocus is enabled and there is a selected cell, in which case that takes precedence since with selectionFollowsFocus enabled, the selected and focused cell should always be the same.

Sorry for the late reply. I will look at constructing a sample for sending via feedback.

This method should only be skipped if selectionFollowsFocus is enabled and there is a selected cell

Agreed, I made sure that this is not enabled anywhere in my code.