I am implementing a simple messenger for my app where the users can chat among themselves. The messenger is based on UICollectionView (JSQMessagesViewController) where each message is represented by one UICollectionView row. Each message also has a top label that is used to display when the message was sent. This label is initially hidden (height=0) and when the user taps the particular message (row), the label gets displayed by setting the height correspondingly. (height=25)
The problem I am facing is the actual animation of displaying the label. (height change). Part of the row overlays the row bellow by several pixels before it gets to it's position. Also when hiding the label back, the animation first sets the height to zero and then the text fades out overlaying part of the message bellow which looks really bad.
So basically what I am trying to achieve is to get rid of those two previously mentioned problems.
Code:
override func collectionView(_ collectionView: JSQMessagesCollectionView!, layout collectionViewLayout: JSQMessagesCollectionViewFlowLayout!, heightForCellTopLabelAt indexPath: IndexPath!) -> CGFloat {
if indexPath == indexPathTapped {
return 25
}
let messageCurrent = messages[indexPath.item]
let messagePrev: JSQMessage? = indexPath.item - 1 >= 0 ? messages[indexPath.item - 1] : nil
if messageCurrent.senderId == messagePrev?.senderId || messagePrev == nil {
return 0
}
else{
return 25
}
}
override func collectionView(_ collectionView: JSQMessagesCollectionView!, didTapMessageBubbleAt indexPath: IndexPath!) {
if let indexPathTapped = indexPathTapped, indexPathTapped == indexPath {
self.indexPathTapped = nil
}
else{
indexPathTapped = indexPath
}
collectionView.reloadItems(at: [indexPath])
// UIView.animate(withDuration: 0.5, delay: 0.0, options: .curveLinear, animations: {
// collectionView.performBatchUpdates({
// collectionView.reloadItems(at: [indexPath])
// }, completion: nil)
// }, completion: nil)
}
Demo: (Sorry for the quality)
I would really appreciate if somebody could help me with this as I have already spent several hours trying to figure it out without getting anywhere.
Thank you in advance!
EDIT:
I tried the solution proposed by @jamesk as following:
override func collectionView(_ collectionView: JSQMessagesCollectionView!, didTapMessageBubbleAt indexPath: IndexPath!) {
if let indexPathTapped = indexPathTapped, indexPathTapped == indexPath {
self.indexPathTapped = nil
}
else{
indexPathTapped = indexPath
}
UIView.animate(withDuration: 0.25) {
collectionView.performBatchUpdates(nil)
}
}
And override the apply
of JSQMessagesCollectionViewCell
:
extension JSQMessagesCollectionViewCell {
override open func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
super.apply(layoutAttributes)
layoutIfNeeded()
}
}
However those changes resulted in:
I also tried the second solution with invalidating the layout:
override func collectionView(_ collectionView: JSQMessagesCollectionView!, didTapMessageBubbleAt indexPath: IndexPath!) {
if let indexPathTapped = indexPathTapped, indexPathTapped == indexPath {
self.indexPathTapped = nil
}
else{
indexPathTapped = indexPath
}
var paths = [IndexPath]()
let itemsCount = collectionView.numberOfItems(inSection: 0)
for i in indexPath.item...itemsCount - 1 {
paths.append(IndexPath(item: i, section: 0))
}
let context = JSQMessagesCollectionViewFlowLayoutInvalidationContext()
context.invalidateItems(at: paths)
UIView.animate(withDuration: 0.25) {
self.collectionView?.collectionViewLayout.invalidateLayout(with: context)
self.collectionView?.layoutIfNeeded()
}
}
Which resulted in the following:
There seem to be two issues. The first issue is that the call to reloadItems(at:)
is limited to cross-fading between the old cell and the new cell—it won't interpolate between the layout attributes for the old cell and the layout attributes for the new cell. The second issue is that there doesn't seem to be any code that instructs your selected cell to perform a layout pass if needed upon new layout attributes being applied to it.
The JSQMessagesViewController framework uses subclasses of UICollectionViewFlowLayout and UICollectionViewFlowLayoutInvalidationContext, so we can leverage the invalidation behaviour of the flow layout when updating and animating items. All that is needed is to invalidate the layout attributes (i.e. position) and delegate metrics (i.e. size) for the items affected by the change in cell height.
The code below was written for use with the Swift example project included in the release_7.3
branch of JSQMessagesViewController:
override func collectionView(_ collectionView: JSQMessagesCollectionView!, didTapMessageBubbleAt indexPath: IndexPath!) {
// Determine the lowest item index affected by the change in cell size.
// Lesser of previous tapped item index (if any) and current tapped item index.
let minItem = min(tappedIndexPath?.item ?? indexPath.item, indexPath.item)
// Update tapped index path.
tappedIndexPath = (tappedIndexPath == indexPath ? nil : indexPath)
// Prepare invalidation context spanning all affected items.
let context = JSQMessagesCollectionViewFlowLayoutInvalidationContext()
let maxItem = collectionView.numberOfItems(inSection: 0) - 1
let indexPaths = (minItem ... maxItem).map { IndexPath(item: $0, section: 0) }
context.invalidateItems(at: indexPaths) // Must include all affected items.
context.invalidateFlowLayoutAttributes = true // Recompute item positions (for all affected items).
context.invalidateFlowLayoutDelegateMetrics = true // Recompute item sizes (needed for tapped item).
UIView.animate(withDuration: 0.25) {
collectionView.collectionViewLayout.invalidateLayout(with: context)
collectionView.layoutIfNeeded() // Ensure layout pass for visible cells.
}
}
The above code should be reasonably performant.
While the positions of affected items must always be recomputed, it isn't necessary to recompute the sizes of all affected items as is done above. It would be sufficient to recompute only the size of the tapped item. But as the effect of the invalidateFlowLayoutDelegateMetrics
property is always applied to every invalidated item, to implement that narrower approach, you would need to use two flow layout invalidation contexts and divide the items between them (or implement a custom invalidation context with corresponding invalidation behaviour). It is probably not worth it unless Instruments tells you otherwise.