Skip to content

Instantly share code, notes, and snippets.

@zackdotcomputer
Last active September 7, 2020 21:45
Show Gist options
  • Save zackdotcomputer/d365bfa5cd7e4a38d45c078b09459da3 to your computer and use it in GitHub Desktop.
Save zackdotcomputer/d365bfa5cd7e4a38d45c078b09459da3 to your computer and use it in GitHub Desktop.
A table view cell that shrinks to avoid the top of the table
//
// ShrinkingTableCell.swift
// ShrinkingTableCell
//
// Created by Zack Sheppard on 9/2/20.
// Copyright © 2020 Zack Sheppard. All rights reserved.
// Available under the MIT License
// Available at https://gist.github.com/zackdotcomputer/d365bfa5cd7e4a38d45c078b09459da3
//
import UIKit
/// The behavior this cell will have on the screen
struct ShrinkingTableCellBehavior {
let cellHeightAsRatioOfWidth: ClosedRange<Double>?
let cellHeightAsPointValues: ClosedRange<Double>?
init(confinedToRatioOfWidth: ClosedRange<Double>) {
cellHeightAsRatioOfWidth = confinedToRatioOfWidth
cellHeightAsPointValues = nil
}
init(confinedToPointSizes: ClosedRange<Double>) {
cellHeightAsRatioOfWidth = nil
cellHeightAsPointValues = confinedToPointSizes
}
static var defaultBehavior: ShrinkingTableCellBehavior {
return self.init(confinedToRatioOfWidth: 0.25...1)
}
}
/// A UITableViewCell that will shrink away from the top of the screen as you scroll.
/// - Note: Usage Tips:
/// + Subclass this class and add your cell's UI to the `shrinkingContentView` view.
/// + It is recommended you use Autolayout to constrain your layout based on that view's size.
/// + If you use Autolayout priorities higher than .defaultHigh, they will overpower the shrinking behavior.
/// + You should feel free to set a custom behavior in your init method or afterwards, to define the
/// bounds for growing and shrinking.
/// + When you're using your cell, you need to listen to your `UITableView`'s scrollViewDidScroll(_:)
/// delegate method and forward calls from there to this class's tableViewDidScroll(_:) function so
/// that the cell can update its layout in response to the scroll event.
class ShrinkingTableCell: UITableViewCell {
/// This wrapper view is used to ensure the cell's height doesn't change from the table view's perspective
let fixedHeightContainer: UIView = UIView()
/// Add your UI to this content view, which will automatically adjust its height as you've requested
public let shrinkingContentView: UIView = UIView()
private var shrinkingContentViewVerticalConstraint: NSLayoutConstraint!
public private(set) var effectiveTopInset: CGFloat = 0 {
didSet {
shrinkingContentViewVerticalConstraint.constant = effectiveTopInset
}
}
public var behavior: ShrinkingTableCellBehavior = ShrinkingTableCellBehavior.defaultBehavior {
didSet {
makeAllConstraints()
}
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupShrinkingViews()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupShrinkingViews()
}
override func prepareForReuse() {
super.prepareForReuse()
effectiveTopInset = 0
}
private func setupShrinkingViews() {
contentView.addSubview(fixedHeightContainer)
fixedHeightContainer.addSubview(shrinkingContentView)
makeAllConstraints()
}
private func makeAllConstraints() {
fixedHeightContainer.removeConstraints(fixedHeightContainer.constraints)
shrinkingContentView.removeConstraints(shrinkingContentView.constraints)
fixedHeightContainer.translatesAutoresizingMaskIntoConstraints = false
shrinkingContentView.translatesAutoresizingMaskIntoConstraints = false
let fhEdgeConstraints = [
fixedHeightContainer.leftAnchor.constraint(equalTo: contentView.leftAnchor),
fixedHeightContainer.rightAnchor.constraint(equalTo: contentView.rightAnchor),
fixedHeightContainer.topAnchor.constraint(equalTo: contentView.topAnchor),
fixedHeightContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
]
let scvEdgeConstraints = [
shrinkingContentView.leftAnchor.constraint(equalTo: fixedHeightContainer.leftAnchor),
shrinkingContentView.rightAnchor.constraint(equalTo: fixedHeightContainer.rightAnchor),
shrinkingContentView.topAnchor.constraint(greaterThanOrEqualTo: fixedHeightContainer.topAnchor),
shrinkingContentView.bottomAnchor.constraint(equalTo: fixedHeightContainer.bottomAnchor)
]
let heightBoundsConstraints: [NSLayoutConstraint]
if let asRatio = behavior.cellHeightAsRatioOfWidth {
heightBoundsConstraints = [
fixedHeightContainer.heightAnchor.constraint(
equalTo: fixedHeightContainer.widthAnchor,
multiplier: CGFloat(asRatio.upperBound)
),
shrinkingContentView.heightAnchor.constraint(
greaterThanOrEqualTo: fixedHeightContainer.widthAnchor,
multiplier: CGFloat(asRatio.lowerBound)
)
]
} else if let asPoints = behavior.cellHeightAsPointValues {
heightBoundsConstraints = [
fixedHeightContainer.heightAnchor.constraint(equalToConstant: CGFloat(asPoints.upperBound)),
shrinkingContentView.heightAnchor.constraint(
greaterThanOrEqualToConstant: CGFloat(asPoints.lowerBound)
)
]
} else {
fatalError("ShrinkingTableViewCell needs to be given size bounds in its behavior")
}
let desiredTopPaddingConstraint = shrinkingContentView.topAnchor.constraint(
equalTo: fixedHeightContainer.topAnchor, constant: 0
)
desiredTopPaddingConstraint.priority = UILayoutPriority.defaultHigh
shrinkingContentViewVerticalConstraint = desiredTopPaddingConstraint
NSLayoutConstraint.activate(
fhEdgeConstraints +
scvEdgeConstraints +
heightBoundsConstraints +
[desiredTopPaddingConstraint]
)
}
/// Call this method whenever your table view containing this cell scrolls, so that this cell can update its appearance.
func tableViewDidScroll(_ tableView: UIScrollView) {
let topShift = tableView.contentOffset.y + tableView.adjustedContentInset.top
// This number will be positive if the frame's top is below the "visible" top
let distanceFromTopOfTableToTopOfCell = self.frame.origin.y - topShift
effectiveTopInset = max(0, -1 * distanceFromTopOfTableToTopOfCell)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment