Skip to content

Commit 391b09c

Browse files
authored
Merge pull request #85 from ekazaev/chore/sticky_cells
Reviewed pinned headers/footers and added pining of the cells as well
2 parents 0576ac9 + 80c367c commit 391b09c

18 files changed

+742
-110
lines changed

ChatLayout.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Pod::Spec.new do |s|
22
s.name = 'ChatLayout'
3-
s.version = '2.0.13'
3+
s.version = '2.1.0'
44
s.summary = 'Chat UI Library. It uses custom UICollectionViewLayout to provide you full control over the presentation.'
55
s.swift_version = '5.10'
66

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
//
2+
// ChatLayout
3+
// ChatItemPinningType.swift
4+
// https://github.com/ekazaev/ChatLayout
5+
//
6+
// Created by Eugene Kazaev in 2020-2025.
7+
// Distributed under the MIT license.
8+
//
9+
// Become a sponsor:
10+
// https://github.com/sponsors/ekazaev
11+
//
12+
13+
import Foundation
14+
15+
// Represents pinning behavour of the element.
16+
public enum ChatItemPinningType: Hashable {
17+
/// Represents top edge of the visible area.
18+
case top
19+
20+
/// Represents bottom edge of the visible area.
21+
case bottom
22+
}

ChatLayout/Classes/Core/ChatLayoutAttributes.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ public final class ChatLayoutAttributes: UICollectionViewLayoutAttributes {
1818
/// Alignment of the current item. Can be changed within `UICollectionViewCell.preferredLayoutAttributesFitting(...)`
1919
public var alignment: ChatItemAlignment = .fullWidth
2020

21+
public var pinningType: ChatItemPinningType? = nil
22+
2123
/// Inter item spacing. Can be changed within `UICollectionViewCell.preferredLayoutAttributesFitting(...)`
2224
public var interItemSpacing: CGFloat = 0
2325

@@ -61,6 +63,7 @@ public final class ChatLayoutAttributes: UICollectionViewLayoutAttributes {
6163
copy.additionalInsets = additionalInsets
6264
copy.visibleBoundsSize = visibleBoundsSize
6365
copy.adjustedContentInsets = adjustedContentInsets
66+
copy.pinningType = pinningType
6467
#if DEBUG
6568
copy.id = id
6669
#endif
@@ -69,9 +72,11 @@ public final class ChatLayoutAttributes: UICollectionViewLayoutAttributes {
6972

7073
/// Returns a Boolean value indicating whether two `ChatLayoutAttributes` are considered equal.
7174
public override func isEqual(_ object: Any?) -> Bool {
72-
super.isEqual(object)
73-
&& alignment == (object as? ChatLayoutAttributes)?.alignment
74-
&& interItemSpacing == (object as? ChatLayoutAttributes)?.interItemSpacing
75+
let chatLayoutAttributes = (object as? ChatLayoutAttributes)
76+
return super.isEqual(object)
77+
&& pinningType == chatLayoutAttributes?.pinningType
78+
&& alignment == chatLayoutAttributes?.alignment
79+
&& interItemSpacing == chatLayoutAttributes?.interItemSpacing
7580
}
7681

7782
/// `ItemKind` represented by this attributes object.

ChatLayout/Classes/Core/ChatLayoutDelegate.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ public protocol ChatLayoutDelegate: AnyObject {
4545
/// - chatLayout: `CollectionViewChatLayout` reference.
4646
/// - sectionIndex: Index of the section.
4747
/// - Returns: `Bool`.
48+
///
49+
/// **NB:** This method will be called only if the `ChatLayoutSettings.pinnableItems` is set to `.supplementaryViews`
4850
func shouldPinHeaderToVisibleBounds(_ chatLayout: CollectionViewChatLayout,
4951
at sectionIndex: Int) -> Bool
5052

@@ -53,9 +55,21 @@ public protocol ChatLayoutDelegate: AnyObject {
5355
/// - chatLayout: `CollectionViewChatLayout` reference.
5456
/// - sectionIndex: Index of the section.
5557
/// - Returns: `Bool`.
58+
///
59+
/// **NB:** This method will be called only if the `ChatLayoutSettings.pinnableItems` is set to `.supplementaryViews`
5660
func shouldPinFooterToVisibleBounds(_ chatLayout: CollectionViewChatLayout,
5761
at sectionIndex: Int) -> Bool
5862

63+
/// `CollectionViewChatLayout` will call this method to ask if it should pin (stick) the cell to the visible bounds in the current layout.
64+
/// - Parameters:
65+
/// - chatLayout: `CollectionViewChatLayout` reference.
66+
/// - indexPath: Index path of the cell.
67+
/// - Returns: `ChatItemPinningType` to configure pinning behaviour or `nil` if pinning is not required.
68+
///
69+
/// **NB:** This method will be called only if the `ChatLayoutSettings.pinnableItems` is set to `.cells`
70+
func pinningTypeForItem(_ chatLayout: CollectionViewChatLayout,
71+
at indexPath: IndexPath) -> ChatItemPinningType?
72+
5973
/// `CollectionViewChatLayout` will call this method to ask what size the item should have.
6074
///
6175
/// **NB:**
@@ -156,6 +170,12 @@ public extension ChatLayoutDelegate {
156170
false
157171
}
158172

173+
/// Default implementation returns: `nil`.
174+
func pinningTypeForItem(_ chatLayout: CollectionViewChatLayout,
175+
at indexPath: IndexPath) -> ChatItemPinningType? {
176+
nil
177+
}
178+
159179
/// Default implementation returns: `false`.
160180
func shouldPinFooterToVisibleBounds(_ chatLayout: CollectionViewChatLayout,
161181
at sectionIndex: Int) -> Bool {

ChatLayout/Classes/Core/ChatLayoutSettings.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ import UIKit
1515

1616
/// `CollectionViewChatLayout` settings.
1717
public struct ChatLayoutSettings: Equatable {
18+
/// Represents type of pinnable elements in the layout.
19+
public enum PinneableItems: Equatable {
20+
/// Pin supplementary views (header and/or footer).
21+
case supplementaryViews
22+
/// Pin cells
23+
case cells
24+
}
25+
1826
/// Estimated item size for `CollectionViewChatLayout`. This value will be used as the initial size of the item and the final size
1927
/// will be calculated using `UICollectionViewCell.preferredLayoutAttributesFitting(...)`.
2028
public var estimatedItemSize: CGSize?
@@ -27,4 +35,7 @@ public struct ChatLayoutSettings: Equatable {
2735

2836
/// Additional insets for the `CollectionViewChatLayout` content.
2937
public var additionalInsets: UIEdgeInsets = .zero
38+
39+
/// Confugures what elements can be pinned in the layout.
40+
public var pinnableItems: PinneableItems = .supplementaryViews
3041
}

ChatLayout/Classes/Core/CollectionViewChatLayout.swift

Lines changed: 56 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ open class CollectionViewChatLayout: UICollectionViewLayout {
180180
static let cachePreviousWidth = PrepareActions(rawValue: 1 << 2)
181181
static let cachePreviousContentInsets = PrepareActions(rawValue: 1 << 3)
182182
static let switchStates = PrepareActions(rawValue: 1 << 4)
183+
static let updatePinnedInfo = PrepareActions(rawValue: 1 << 5)
183184
}
184185

185186
private struct InvalidationActions: OptionSet {
@@ -219,8 +220,6 @@ open class CollectionViewChatLayout: UICollectionViewLayout {
219220

220221
private var _supportSelfSizingInvalidation: Bool = false
221222

222-
var hasPinnedHeaderOrFooter: Bool = false
223-
224223
// MARK: IOS 15.1 fix flags
225224

226225
private var needsIOS15_1IssueFix: Bool {
@@ -331,6 +330,11 @@ open class CollectionViewChatLayout: UICollectionViewLayout {
331330
reconfigureItemsIndexPaths = indexPaths
332331
}
333332

333+
/// Returns index path of currently pinned item.
334+
open func indexPathForItePinnedAt(_ pinningType: ChatItemPinningType) -> IndexPath? {
335+
controller.pinnedIndexPaths[pinningType]?.current
336+
}
337+
334338
// MARK: Providing Layout Attributes
335339

336340
/// Tells the layout object to update the current layout.
@@ -350,10 +354,6 @@ open class CollectionViewChatLayout: UICollectionViewLayout {
350354
contentOffsetBeforeUpdate = nil
351355
}
352356

353-
if prepareActions.contains(.updateLayoutMetrics) || prepareActions.contains(.recreateSectionModels) {
354-
hasPinnedHeaderOrFooter = false
355-
}
356-
357357
if prepareActions.contains(.recreateSectionModels) {
358358
var sections: ContiguousArray<SectionModel<CollectionViewChatLayout>> = []
359359
for sectionIndex in 0..<collectionView.numberOfSections {
@@ -386,8 +386,6 @@ open class CollectionViewChatLayout: UICollectionViewLayout {
386386
footer: footer,
387387
items: items,
388388
collectionLayout: self)
389-
section.set(shouldPinHeaderToVisibleBounds: shouldPinHeaderToVisibleBounds(at: sectionIndex))
390-
section.set(shouldPinFooterToVisibleBounds: shouldPinFooterToVisibleBounds(at: sectionIndex))
391389
section.assembleLayout()
392390
sections.append(section)
393391
}
@@ -438,6 +436,12 @@ open class CollectionViewChatLayout: UICollectionViewLayout {
438436
cachedCollectionViewSize = collectionView.bounds.size
439437
}
440438

439+
if prepareActions.contains(.updatePinnedInfo) ||
440+
prepareActions.contains(.updateLayoutMetrics) ||
441+
prepareActions.contains(.recreateSectionModels) {
442+
controller.updatePinnedInfo(at: state)
443+
}
444+
441445
prepareActions = []
442446
}
443447

@@ -478,7 +482,7 @@ open class CollectionViewChatLayout: UICollectionViewLayout {
478482
guard !dontReturnAttributes else {
479483
return nil
480484
}
481-
let attributes = controller.itemAttributes(for: indexPath.itemPath, kind: .cell, at: state)
485+
let attributes = controller.itemAttributes(for: indexPath.itemPath, kind: .cell, at: state, withPinnning: true)
482486
return attributes
483487
}
484488

@@ -490,7 +494,7 @@ open class CollectionViewChatLayout: UICollectionViewLayout {
490494
}
491495

492496
let kind = ItemKind(elementKind)
493-
let attributes = controller.itemAttributes(for: indexPath.itemPath, kind: kind, at: state)
497+
let attributes = controller.itemAttributes(for: indexPath.itemPath, kind: kind, at: state, withPinnning: true)
494498

495499
return attributes
496500
}
@@ -512,6 +516,7 @@ open class CollectionViewChatLayout: UICollectionViewLayout {
512516
let newBounds = collectionView.bounds
513517
let heightDifference = oldBounds.height - newBounds.height
514518
controller.proposedCompensatingOffset += heightDifference + (oldBounds.origin.y - newBounds.origin.y)
519+
controller.updatePinnedInfo(at: state)
515520
}
516521

517522
/// Cleans up after any animated changes to the view’s bounds or after the insertion or deletion of items.
@@ -542,6 +547,7 @@ open class CollectionViewChatLayout: UICollectionViewLayout {
542547
|| (_supportSelfSizingInvalidation ? (item.size.height - preferredMessageAttributes.size.height).rounded() != 0 : false)
543548
|| item.alignment != preferredMessageAttributes.alignment
544549
|| item.interItemSpacing != preferredMessageAttributes.interItemSpacing
550+
|| item.pinningType != preferredMessageAttributes.pinningType
545551

546552
return shouldInvalidateLayout
547553
}
@@ -566,9 +572,11 @@ open class CollectionViewChatLayout: UICollectionViewLayout {
566572
let newItemSize = itemSize(with: preferredMessageAttributes)
567573
let newItemAlignment = alignment(for: preferredMessageAttributes.kind, at: preferredMessageAttributes.indexPath)
568574
let newInterItemSpacing = interItemSpacing(for: preferredMessageAttributes.kind, at: preferredMessageAttributes.indexPath)
575+
let newPinningType = pinningType(for: preferredMessageAttributes.kind, at: preferredMessageAttributes.indexPath)
569576
controller.update(preferredSize: newItemSize,
570577
alignment: newItemAlignment,
571578
interItemSpacing: newInterItemSpacing,
579+
pinningType: newPinningType,
572580
for: preferredAttributesItemPath,
573581
kind: preferredMessageAttributes.kind,
574582
at: state)
@@ -578,7 +586,9 @@ open class CollectionViewChatLayout: UICollectionViewLayout {
578586
let heightDifference = newItemSize.height - originalAttributes.size.height
579587
let isAboveBottomEdge = originalAttributes.frame.minY.rounded() <= visibleBounds.maxY.rounded()
580588

589+
let shouldApplyCompensations = !controller.isPinnedItem(indexPath: preferredMessageAttributes.indexPath, kind: preferredMessageAttributes.kind)
581590
if heightDifference != 0,
591+
shouldApplyCompensations,
582592
(keepContentOffsetAtBottomOnBatchUpdates && controller.contentHeight(at: state).rounded() + heightDifference > visibleBounds.height.rounded()) || isUserInitiatedScrolling,
583593
isAboveBottomEdge {
584594
let offsetCompensation: CGFloat = min(controller.contentHeight(at: state) - collectionView!.frame.height + adjustedContentInset.bottom + adjustedContentInset.top, heightDifference)
@@ -588,7 +598,8 @@ open class CollectionViewChatLayout: UICollectionViewLayout {
588598

589599
if let attributes = controller.itemAttributes(for: preferredAttributesItemPath, kind: preferredMessageAttributes.kind, at: state)?.typedCopy() {
590600
layoutAttributesForPendingAnimation?.frame = attributes.frame
591-
if state == .afterUpdate {
601+
if state == .afterUpdate,
602+
shouldApplyCompensations {
592603
controller.totalProposedCompensatingOffset += heightDifference
593604
controller.offsetByTotalCompensation(attributes: layoutAttributesForPendingAnimation, for: state, backward: true)
594605
if controller.insertedIndexes.contains(preferredMessageAttributes.indexPath) ||
@@ -626,10 +637,11 @@ open class CollectionViewChatLayout: UICollectionViewLayout {
626637
let shouldInvalidateLayout = cachedCollectionViewSize != .some(newBounds.size) ||
627638
cachedCollectionViewInset != .some(adjustedContentInset) ||
628639
invalidationActions.contains(.shouldInvalidateOnBoundsChange)
629-
|| (isUserInitiatedScrolling && state == .beforeUpdate)
640+
|| ((isUserInitiatedScrolling || !controller.pinnedIndexPaths.isEmpty) && state == .beforeUpdate)
630641

631642
invalidationActions.remove(.shouldInvalidateOnBoundsChange)
632-
return shouldInvalidateLayout || hasPinnedHeaderOrFooter
643+
prepareActions.insert(.updatePinnedInfo)
644+
return shouldInvalidateLayout
633645
}
634646

635647
/// Retrieves a context object that defines the portions of the layout that should change when a bounds change occurs.
@@ -967,7 +979,13 @@ extension CollectionViewChatLayout {
967979
} else {
968980
interItemSpacing = 0
969981
}
970-
return ItemModel.Configuration(alignment: alignment(for: element, at: indexPath), preferredSize: itemSize.estimated, calculatedSize: itemSize.exact, interItemSpacing: interItemSpacing)
982+
return ItemModel.Configuration(
983+
alignment: alignment(for: element, at: indexPath),
984+
pinningType: pinningType(for: element, at: indexPath),
985+
preferredSize: itemSize.estimated,
986+
calculatedSize: itemSize.exact,
987+
interItemSpacing: interItemSpacing
988+
)
971989
}
972990

973991
private func estimatedSize(for element: ItemKind, at indexPath: IndexPath) -> (estimated: CGSize, exact: CGSize?) {
@@ -1016,6 +1034,30 @@ extension CollectionViewChatLayout {
10161034
return delegate.alignmentForItem(self, of: element, at: indexPath)
10171035
}
10181036

1037+
private func pinningType(for kind: ItemKind, at indexPath: IndexPath) -> ChatItemPinningType? {
1038+
guard let delegate else {
1039+
return nil
1040+
}
1041+
let pinningType: ChatItemPinningType?
1042+
if kind == .cell,
1043+
settings.pinnableItems == .cells {
1044+
pinningType = delegate.pinningTypeForItem(self, at: indexPath)
1045+
} else if settings.pinnableItems == .supplementaryViews {
1046+
if kind == .header,
1047+
delegate.shouldPinHeaderToVisibleBounds(self, at: indexPath.section) {
1048+
pinningType = .top
1049+
} else if kind == .footer,
1050+
delegate.shouldPinFooterToVisibleBounds(self, at: indexPath.section) {
1051+
pinningType = .bottom
1052+
} else {
1053+
pinningType = nil
1054+
}
1055+
} else {
1056+
pinningType = nil
1057+
}
1058+
return pinningType
1059+
}
1060+
10191061
private var estimatedItemSize: CGSize {
10201062
guard let estimatedItemSize = settings.estimatedItemSize else {
10211063
guard collectionView != nil else {
@@ -1056,14 +1098,6 @@ extension CollectionViewChatLayout: ChatLayoutRepresentation {
10561098
delegate?.shouldPresentFooter(self, at: sectionIndex) ?? false
10571099
}
10581100

1059-
func shouldPinHeaderToVisibleBounds(at sectionIndex: Int) -> Bool {
1060-
delegate?.shouldPinHeaderToVisibleBounds(self, at: sectionIndex) ?? false
1061-
}
1062-
1063-
func shouldPinFooterToVisibleBounds(at sectionIndex: Int) -> Bool {
1064-
delegate?.shouldPinFooterToVisibleBounds(self, at: sectionIndex) ?? false
1065-
}
1066-
10671101
func interSectionSpacing(at sectionIndex: Int) -> CGFloat {
10681102
let interItemSpacing: CGFloat
10691103
if let delegate,

ChatLayout/Classes/Core/Model/ItemModel.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ struct ItemModel {
1717
struct Configuration {
1818
let alignment: ChatItemAlignment
1919

20+
let pinningType: ChatItemPinningType?
21+
2022
let preferredSize: CGSize
2123

2224
let calculatedSize: CGSize?
@@ -36,6 +38,8 @@ struct ItemModel {
3638

3739
var alignment: ChatItemAlignment
3840

41+
var pinningType: ChatItemPinningType?
42+
3943
var interItemSpacing: CGFloat
4044

4145
var size: CGSize {
@@ -57,6 +61,7 @@ struct ItemModel {
5761
interItemSpacing = configuration.interItemSpacing
5862
calculatedSize = configuration.calculatedSize
5963
calculatedOnce = configuration.calculatedSize != nil
64+
pinningType = configuration.pinningType
6065
}
6166

6267
// We are just resetting `calculatedSize` if needed as the actual size will be found in

0 commit comments

Comments
 (0)