Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/ConversationView/ConversationCollectionView.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
public import SignalUI

public protocol ConversationCollectionViewDelegate: AnyObject {

    func collectionViewWillChangeSize(from oldSize: CGSize, to size: CGSize)
    func collectionViewDidChangeSize(from oldSize: CGSize, to size: CGSize)
    func collectionViewWillAnimate()
    func collectionViewShouldRecognizeSimultaneously(with otherGestureRecognizer: UIGestureRecognizer) -> Bool
}

public class ConversationCollectionView: UICollectionView {

    weak var layoutDelegate: ConversationCollectionViewDelegate?

    override public var frame: CGRect {
        get { super.frame }
        set {
            AssertIsOnMainThread()
            guard newValue.width > 0, newValue.height > 0 else {
                // Ignore iOS Auto Layout's tendency to temporarily zero out the
                // frame of this view during the layout process.
                //
                // The conversation view has an invariant that the collection view
                // should always have a "reasonable" (correct width, non-zero height)
                // size.  This lets us manipulate scroll state at all times, especially
                // before the view has been presented for the first time.  This
                // invariant also saves us from needing all sorts of ugly and incomplete
                // hacks in the conversation view's code.
                return
            }
            let oldValue = frame
            let isChanging = oldValue.size != newValue.size
            if isChanging {
                layoutDelegate?.collectionViewWillChangeSize(from: oldValue.size, to: newValue.size)
            }
            super.frame = newValue
            if isChanging {
                layoutDelegate?.collectionViewDidChangeSize(from: oldValue.size, to: newValue.size)
            }
        }
    }

    override public var bounds: CGRect {
        get { super.bounds }
        set {
            AssertIsOnMainThread()
            guard newValue.width > 0, newValue.height > 0 else {
                // Ignore iOS Auto Layout's tendency to temporarily zero out the
                // frame of this view during the layout process.
                //
                // The conversation view has an invariant that the collection view
                // should always have a "reasonable" (correct width, non-zero height)
                // size.  This lets us manipulate scroll state at all times, especially
                // before the view has been presented for the first time.  This
                // invariant also saves us from needing all sorts of ugly and incomplete
                // hacks in the conversation view's code.
                return
            }
            let oldValue = bounds
            let isChanging = oldValue.size != newValue.size
            if isChanging {
                layoutDelegate?.collectionViewWillChangeSize(from: oldValue.size, to: newValue.size)
            }
            super.bounds = newValue
            if isChanging {
                layoutDelegate?.collectionViewDidChangeSize(from: oldValue.size, to: newValue.size)
            }
        }
    }

    override public func setContentOffset(_ contentOffset: CGPoint, animated: Bool) {
        AssertIsOnMainThread()
        if animated {
            layoutDelegate?.collectionViewWillAnimate()
        }
        super.setContentOffset(contentOffset, animated: animated)
    }

    override public var contentOffset: CGPoint {
        get { super.contentOffset }
        set {
            AssertIsOnMainThread()
            if contentSize.height < 1, newValue.y <= 0 {
                // [UIScrollView _adjustContentOffsetIfNecessary] resets the content
                // offset to zero under a number of undocumented conditions.  We don't
                // want this behavior; we want fine-grained control over the default
                // scroll state of the message view.
                //
                // [UIScrollView _adjustContentOffsetIfNecessary] is called in
                // response to many different events; trying to prevent them all is
                // whack-a-mole.
                //
                // It's not safe to override [UIScrollView _adjustContentOffsetIfNecessary],
                // since its a private API.
                //
                // We can avoid the issue by simply ignoring attempt to reset the content
                // offset to zero before the collection view has determined its content size.
                return
            }
            super.contentOffset = newValue
        }
    }

    override public func scrollRectToVisible(_ rect: CGRect, animated: Bool) {
        if animated {
            layoutDelegate?.collectionViewWillAnimate()
        }
        super.scrollRectToVisible(rect, animated: animated)
    }

    func gestureRecognizer(
        _ gestureRecognizer: UIGestureRecognizer,
        shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer,
    ) -> Bool {
        guard let layoutDelegate else { return false }
        return layoutDelegate.collectionViewShouldRecognizeSimultaneously(with: otherGestureRecognizer)
    }

    // MARK: CVC

    typealias CVCPerformBatchUpdatesBlock = () -> Void
    typealias CVCPerformBatchUpdatesCompletion = (Bool) -> Void

    func cvc_reloadData(animated: Bool, cvc: ConversationViewController) {
        AssertIsOnMainThread()

        cvc.layout.willReloadData()
        if animated {
            super.reloadData()
        } else {
            UIView.performWithoutAnimation {
                super.reloadData()
            }
        }
        cvc.layout.invalidateLayout()
        cvc.layout.didReloadData()
    }

    func cvc_performBatchUpdates(
        _ batchUpdates: @escaping CVCPerformBatchUpdatesBlock,
        completion: @escaping CVCPerformBatchUpdatesCompletion,
        animated: Bool,
        scrollContinuity: ScrollContinuity,
        lastKnownDistanceFromBottom: CGFloat?,
        cvc: ConversationViewController,
    ) {
        AssertIsOnMainThread()

        let updateBlock = {
            let layout = cvc.layout
            layout.willPerformBatchUpdates(
                scrollContinuity: scrollContinuity,
                lastKnownDistanceFromBottom: lastKnownDistanceFromBottom,
            )
            super.performBatchUpdates(batchUpdates) { (finished: Bool) in
                AssertIsOnMainThread()
                completion(finished)
            }
            layout.didPerformBatchUpdates()
        }

        if animated {
            updateBlock()
        } else {
            // HACK: We use `UIView.animateWithDuration:0` rather than `UIView.performWithAnimation` to work around a
            // UIKit Crash like:
            //
            //     *** Assertion failure in -[ConversationViewLayout prepareForCollectionViewUpdates:],
            //     /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKit_Sim/UIKit-3600.7.47/UICollectionViewLayout.m:760
            //     *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'While
            //     preparing update a visible view at <NSIndexPath: 0xc000000011c00016> {length = 2, path = 0 - 142}
            //     wasn't found in the current data model and was not in an update animation. This is an internal
            //     error.'
            //
            // I'm unclear if this is a bug in UIKit, or if we're doing something crazy in
            // ConversationViewLayout#prepareLayout. To reproduce, rapidily insert and delete items into the
            // conversation. See `DebugUIMessages#thrashCellsInThread:`
            UIView.animate(withDuration: 0, animations: updateBlock)
        }
    }
}