iOS 13 UIContextualAction completion triggers unexpected keyboard dismissal - uitableview

I have a UITableView with cells that have swappable UIContextualActions to delete or rename (edit) individual cell's TextFields.
Since I made the switch to Swift 5 / iOS 13, triggering the rename UIContextualAction on these cells causes the keyboard to launch and instantly dismiss before the user has a chance to type. Not only does the keyboard go away, the particular cell I'm trying to edit becomes completely empty, and the following warning gets generated:
[Snapshotting] Snapshotting a view (0x10c90a470, _UIReplicantView) that has not been rendered at least once requires afterScreenUpdates:YES.
Below is the code for the rename UIContextualAction:
let actionRename = UIContextualAction(style: .normal, title: "") { (action, view, completionHandler) in
let cell = self.tableLayers.cellForRow(at: indexPath) as! LayerUITableViewCell
cell.layerTitle.isEnabled = true // enable UITextField editing
cell.layerTitle.becomeFirstResponder() // launch keyboard
cell.layerTitle.selectedTextRange = cell.layerTitle.textRange(from: (cell.layerTitle.beginningOfDocument), to: (cell.layerTitle.endOfDocument)) // select all text
completionHandler(true)
} // end of let actionRename
I'm guessing the animation of the UIContextual action is somehow triggering the keyboard's resignFirstResponder.
To summarize, prior to swift 5/iOS 13, the order of events went something like this:
user swipes cell left/right
user hits UIContextual button
cell returns to center
text gets selected
keyboard launches
user types, hits return
keyboard resignFirstResponder
Whereas the behavior I'm seeing after the migration looks like this:
user swipes cell left/right
user hits UIContextual button
text gets selected
keyboard launches
cell returns to center (which somehow triggers resignFirstResponder)
keyboard resignFirstResponder
Update 2019/10/02
I have confirmed that it's the cell animation that is causing the premature keyboard dismissal. If I introduce a delay after the completionHandler as follows:
let actionRename = UIContextualAction(style: .normal, title: "") { (action, view, completionHandler) in
completionHandler(true)
self.perform(#selector(self.layerRenameDos), with: nil, afterDelay: 1.0)
// layerRenameDos has the editing/firstResponder code from above
} // end of let actionRename
With this change, the cell animates back to center, keyboard launches, and I'm able to type away. This, however, is obviously a hacky work-around. Any suggestions would be appreciated

I think this is a better solution. Just be sure to execute your code on the next runloop.
CATransaction.begin()
CATransaction.setCompletionBlock {
DispatchQueue.main.async {
//your code on the next runloop after the animation has finished
}
}
complitionHandler(true)
// or tableView.setEditing(false, animated: true)
CATransaction.commit()

Related

What is "stable identifier" for SwiftUI Cell?

While listening Use SwiftUI with UIKit (16:54), I heard, that the reporter said:
"When defining swipe actions, make sure your buttons perform their actions using a stable identifier for the item represented.
Do not use the index path, as it may change while the cell is visible, causing the swipe actions to act on the wrong item."
- what??? All these years I was fighting with prepareForReuse() and indexPath in cells trying to somehow fix bugs related to cell reusing.
What is this "stable identifier"?
Why does no one talk about it?
On stackoverflow you can find answers only related to prepareForReuse() function. No "stable identifier".
Is it reuseIdentifier?
If so, how I suppose to use it?
Creating for each cell its own reuseIdentifier, like this:
for index in 0..<dataSourceArray.count {
tableView.register(MyTableViewCell.self, forCellReuseIdentifier: "ReuseIdForMyCell" + "\(index)")
}
She made a mistake, if you download the sample associated with the video you'll see the deleteHandler captures the item from outside already, it doesn't look it up again when the handler is invoked. She was trying to say that if you look up an item in the handler then there is a chance if other rows have been added or removed then its index will have changed so if you use the rows old index to look up the item then you would delete the wrong one. But since no lookup is required that will never happen, so she shouldn't have even mentioned it. Here is the code in question:
// Configures a list cell to display a medical condition.
private func configureMedicalConditionCell(_ cell: UICollectionViewListCell, for item: MedicalCondition) {
cell.accessories = [.delete()]
// Create a handler to execute when the cell's delete swipe action button is triggered.
let deleteHandler: () -> Void = { [weak self] in
// Make sure to use the item itself (or its stable identifier) in any escaping closures, such as
// this delete handler. Do not capture the index path of the cell, as a cell's index path will
// change when other items before it are inserted or deleted, leaving the closure with a stale
// index path that will cause the wrong item to be deleted!
self?.dataStore.delete(item)
}
// Configure the cell with a UIHostingConfiguration inside the cell's configurationUpdateHandler so
// that the SwiftUI content in the cell can change based on whether the cell is editing. This handler
// is executed before the cell first becomes visible, and anytime the cell's state changes.
cell.configurationUpdateHandler = { cell, state in
cell.contentConfiguration = UIHostingConfiguration {
MedicalConditionView(condition: item, isEditable: state.isEditing)
.swipeActions(edge: .trailing) {
Button(role: .destructive, action: deleteHandler) {
Label("Delete", systemImage: "trash")
}
}
}
}
}

Get an event when UIBarButtonItem menu is displayed

We all know how to make a simple tap on a bar button item present a menu (introduced on iOS 14):
let act = UIAction(title: "Howdy") { act in
print("Howdy")
}
let menu = UIMenu(title: "", children: [act])
self.bbi.menu = menu // self.bbi is the bar button item
So far, so good. But presenting the menu isn't the only thing I want to do when the bar button item is tapped. As long as the menu is showing, I need to pause my game timers, and so on. So I need to get an event telling me that the button has been tapped.
I don't want this tap event to be different from the producing of the menu; for example, I don't want to attach a target and action to my button, because if I do that, then the menu production is a different thing that happens only when the user long presses on the button. I want the menu to appear on the tap, and receive an event telling me that this is happening.
This must be a common issue, so how are people solving it?
The only way I could find was to use UIDeferredMenuElement to perform something on a tap of the menu. However, the problem is that you have to recreate the entire menu and assign it to the bar button item again inside the deferred menu element's elementProvider block in order to get future tap events, as you can see in this toy example here:
class YourViewController: UIViewController {
func menu(for barButtonItem: UIBarButtonItem) -> UIMenu {
UIMenu(title: "Some Menu", children: [UIDeferredMenuElement { [weak self, weak barButtonItem] completion in
guard let self = self, let barButtonItem = barButtonItem else { return }
print("Menu shown - pause your game timers and such here")
// Create your menu's real items here:
let realMenuElements = [UIAction(title: "Some Action") { _ in
print("Menu action fired")
}]
// Hand your real menu elements back to the deferred menu element
completion(realMenuElements)
// Recreate the menu. This is necessary in order to get this block to
// fire again on future taps of the bar button item.
barButtonItem.menu = self.menu(for: barButtonItem)
}])
}
override func viewDidLoad() {
super.viewDidLoad()
let someBarButtonItem = UIBarButtonItem(systemItem: .done)
someBarButtonItem.menu = menu(for: someBarButtonItem)
navigationItem.rightBarButtonItem = someBarButtonItem
}
}
Also, it looks like starting in iOS 15 there's a class method on UIDeferredMenuElement called uncached(_:) that creates a deferred menu element that fires its elementProvider block every time the bar button item is tapped instead of just the first time, which would mean you would not have to recreate the menu as in the example above.

Having some weird animation bug using TrailingSwipeActionsConfigurationForRowAt to delete cells

I'm trying to simply delete cells with this method of tableView. For some reason, doing so creates this weird animation bug that I can't figure out. Here is my code:
let deleteSubcatAction = UIContextualAction(style: .destructive, title: "Delete") { (action, view, handler) in
print("Delete Subcategory Action Tapped")
let subcategory = Global.budget.categories[indexPath.section].subcategories[indexPath.row]
Global.budget.categories[indexPath.section].subcategories.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .left) }
deleteSubcatAction.backgroundColor = .red
let rowConfig = UISwipeActionsConfiguration(actions: [deleteSubcatAction])
return rowConfig
When I drag the cell to the left, I see the delete action as I expect and pressing the action calls the method as it should. However, the cell isn't removed from the table. Instead, the actions stay locked in place and the rest of the content of the cell slides back to the right as if I had released my finger without pressing an action. Then, if I tap elsewhere in my app, the actions minimize back to the right, and the cell simply disappears without any sort of removal animation. Here's a video of the bug I recorded. What's going on here?
First thing that pops out is that you aren't calling the completion handler (in your case, the handler property) at the end of your UIContextualAction closure. As in, handler(true).
The second thing is that you aren't doing anything to ensure that the action is properly animated in the table view. The non-intrusive way of doing this (without reloading the entire table view from the data source) is by using:
tableView.beginUpdates()
tableview.endUpdates()
See the API docs for more info.
P.S. Ideally, you should have your data source hooked up to your table view in such a way that the table view reacts to events triggered by observers of your data source, rather than deleting table rows "manually" in response to a UI action.

UIKeyboardDidShow triggers too often?

In order to display a text field right above the user's keyboard, I overrode inputAccessoryView in my custom view controller.
I also made sure that the view controller may become the first responder by overriding canBecomeFirstResponder (and returning true) and by calling self.becomeFirstResponder() in viewWillAppear().
Now, as I am displaying some messages as UICollectionViewCells in my view controller, I want to scroll down whenever the keyboard shows up. So I added a notification in viewDidLoad():
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: Notification.Name.UIKeyboardDidShow, object: nil)
keyboardDidShow() then calls the scrolling function:
#objc private final func scrollToLastMessage() {
// ('messages' holds all messages, one cell represents a message.)
guard messages.count > 0 else { return }
let indexPath = IndexPath(item: self.messages.count - 1, section: 0)
self.collectionView?.scrollToItem(at: indexPath, at: .bottom, animated: true)
}
Indeed, by setting breakpoints in Xcode, I found out that the function gets triggered after the keyboard has appeared. But additionally, it also triggers after I resigned the first responder (f.ex. by hitting the return key [I resign the first responder and return true in textFieldShouldReturn ]) and the keyboard has disappeared. Although I think that it shouldn't: as the Apple docs say:
Posted immediately after the display of the keyboard.
The notification also triggers when accessing the view controller, so after the main view has appeared and when clicking on a (customized) UICollectionViewCell (the cell does not have any editable content, only static labels or image views, so the keyboard shouldn't even appear).
To give some more information: I pretty much followed this tutorial on Youtube: https://www.youtube.com/watch?v=ky7YRh01by8
The UIKeyboardDidShow notification may be posted more often than you might expect, not just when it initially appears. For example, when the frame changes after it was already visible, UIKeyboardDidShow is posted.
However you can know if the keyboard is truly visible by inspecting the keyboard's end frame from within the userInfo dictionary. This will tell you its size and position on screen, which you can then use to determine how best to react in your user interface.

correct to use rac_prepareForReuseSignal

when keyboard show, i hope cell was scroll up, so i would like to observer if some textview was become first responder
so:
cell.textView.rac_signalForSelector("becomeFirstResponder")
.takeUntil(cell.rac_prepareForReuseSignal)
.flattenMap { (x) -> RACStream! in
return NSNotificationCenter.defaultCenter().rac_addObserverForName(UIKeyboardDidShowNotification, object: nil)
}
.subscribeNext { [weak self](notify) -> Void in
__logln("be4")
}
however when i click at cell.textView, the "be4" was output 4 times
then when i leave the pop the viewcontroller and push back,
and click at a cell.textView again, the output was 8 times
it seem that, the singal was not clear when cell was reuse
I get the answer, it was because the SoGou input method, it send three time UIKeyboardDidShowNotification notify
....

Resources