SimpleX-Chat/apps/ios/Shared/Views/Chat/EndlessScrollView.swift

715 lines
33 KiB
Swift

//
// EndlessScrollView.swift
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 25.01.2025.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct ScrollRepresentable<Content: View, ScrollItem>: UIViewControllerRepresentable where ScrollItem : Identifiable, ScrollItem: Hashable {
let scrollView: EndlessScrollView<ScrollItem>
let content: (Int, ScrollItem) -> Content
func makeUIViewController(context: Context) -> ScrollController {
ScrollController.init(scrollView: scrollView, content: content)
}
func updateUIViewController(_ controller: ScrollController, context: Context) {}
class ScrollController: UIViewController {
let scrollView: EndlessScrollView<ScrollItem>
fileprivate var items: [ScrollItem] = []
fileprivate var content: ((Int, ScrollItem) -> Content)!
fileprivate init(scrollView: EndlessScrollView<ScrollItem>, content: @escaping (Int, ScrollItem) -> Content) {
self.scrollView = scrollView
self.content = content
super.init(nibName: nil, bundle: nil)
self.view = scrollView
scrollView.createCell = createCell
scrollView.updateCell = updateCell
}
required init?(coder: NSCoder) { fatalError() }
private func createCell(_ index: Int, _ items: [ScrollItem], _ cellsToReuse: inout [UIView]) -> UIView {
let item: ScrollItem? = index >= 0 && index < items.count ? items[index] : nil
let cell: UIView
if #available(iOS 16.0, *), false {
let c: UITableViewCell = cellsToReuse.isEmpty ? UITableViewCell() : cellsToReuse.removeLast() as! UITableViewCell
if let item {
c.contentConfiguration = UIHostingConfiguration { self.content(index, item) }
.margins(.all, 0)
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
}
cell = c
} else {
let c = cellsToReuse.isEmpty ? HostingCell<Content>() : cellsToReuse.removeLast() as! HostingCell<Content>
if let item {
c.set(content: self.content(index, item), parent: self)
}
cell = c
}
cell.isHidden = false
cell.backgroundColor = .clear
let size = cell.systemLayoutSizeFitting(CGSizeMake(scrollView.bounds.width, CGFloat.greatestFiniteMagnitude))
cell.frame.size.width = scrollView.bounds.width
cell.frame.size.height = size.height
return cell
}
private func updateCell(cell: UIView, _ index: Int, _ items: [ScrollItem]) {
let item = items[index]
if #available(iOS 16.0, *), false {
(cell as! UITableViewCell).contentConfiguration = UIHostingConfiguration { self.content(index, item) }
.margins(.all, 0)
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
} else {
if let cell = cell as? HostingCell<Content> {
cell.set(content: self.content(index, item), parent: self)
} else {
fatalError("Unexpected Cell Type for: \(item)")
}
}
let size = cell.systemLayoutSizeFitting(CGSizeMake(scrollView.bounds.width, CGFloat.greatestFiniteMagnitude))
cell.frame.size.width = scrollView.bounds.width
cell.frame.size.height = size.height
cell.setNeedsLayout()
}
}
}
class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestureRecognizerDelegate where ScrollItem : Identifiable, ScrollItem: Hashable {
/// Stores actual state of the scroll view and all elements drawn on the screen
let listState: ListState = ListState()
/// Just some random big number that will probably be enough to scrolling down and up without reaching the end
var initialOffset: CGFloat = 100000000
/// Default item id when no items in the visible items list. Something that will never be in real data
fileprivate static var DEFAULT_ITEM_ID: any Hashable { get { Int64.min } }
/// Storing an offset that was already used for laying down content to be able to see the difference
var prevProcessedOffset: CGFloat = 0
/// When screen is being rotated, it's important to track the view size and adjust scroll offset accordingly because the view doesn't know that the content
/// starts from bottom and ends at top, not vice versa as usual
var oldScreenHeight: CGFloat = 0
/// Not 100% correct height of the content since the items loaded lazily and their dimensions are unkown until they are on screen
var estimatedContentHeight: ContentHeight = ContentHeight()
/// Specify here the value that is small enough to NOT see any weird animation when you scroll to items. Minimum expected item size is ok. Scroll speed depends on it too
var averageItemHeight: CGFloat = 30
/// This is used as a multiplier for difference between current index and scrollTo index using [averageItemHeight] as well. Increase it to get faster speed
var scrollStepMultiplier: CGFloat = 0.37
/// Adds content padding to top
var insetTop: CGFloat = 100
/// Adds content padding to bottom
var insetBottom: CGFloat = 100
var scrollToItemIndexDelayed: Int? = nil
/// The second scroll view that is used only for purpose of displaying scroll bar with made-up content size and scroll offset that is gathered from main scroll view, see [estimatedContentHeight]
let scrollBarView: UIScrollView = UIScrollView(frame: .zero)
/// Stores views that can be used to hold new content so it will be faster to replace something than to create the whole view from scratch
var cellsToReuse: [UIView] = []
/// Enable debug to see hundreds of logs
var debug: Bool = false
var createCell: (Int, [ScrollItem], inout [UIView]) -> UIView? = { _, _, _ in nil }
var updateCell: (UIView, Int, [ScrollItem]) -> Void = { cell, _, _ in }
override init(frame: CGRect) {
super.init(frame: frame)
self.delegate = self
}
required init?(coder: NSCoder) { fatalError() }
class ListState: NSObject {
/// Will be called on every change of the items array, visible items, and scroll position
var onUpdateListener: () -> Void = {}
/// Items that were used to lay out the screen
var items: [ScrollItem] = [] {
didSet {
onUpdateListener()
}
}
/// It is equai to the number of [items]
var totalItemsCount: Int {
items.count
}
/// The items with their positions and other useful information. Only those that are visible on screen
var visibleItems: [EndlessScrollView<ScrollItem>.VisibleItem] = []
/// Index in [items] of the first item on screen. This is intentiallty not derived from visible items because it's is used as a starting point for laying out the screen
var firstVisibleItemIndex: Int = 0
/// Unique item id of the first visible item on screen
var firstVisibleItemId: any Hashable = EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
/// Item offset of the first item on screen. Most of the time it's non-positive but it can be positive as well when a user produce overscroll effect on top/bottom of the scroll view
var firstVisibleItemOffset: CGFloat = -100
/// Index of the last visible item on screen
var lastVisibleItemIndex: Int {
visibleItems.last?.index ?? 0
}
/// Specifies if visible items cover the whole screen or can cover it (if overscrolled)
var itemsCanCoverScreen: Bool = false
/// Whether there is a non-animated scroll to item in progress or not
var isScrolling: Bool = false
/// Whether there is an animated scroll to item in progress or not
var isAnimatedScrolling: Bool = false
override init() {
super.init()
}
}
class VisibleItem {
let index: Int
let item: ScrollItem
let view: UIView
var offset: CGFloat
init(index: Int, item: ScrollItem, view: UIView, offset: CGFloat) {
self.index = index
self.item = item
self.view = view
self.offset = offset
}
}
class ContentHeight {
/// After that you should see overscroll effect. When scroll positon is far from
/// top/bottom items, these values are estimated based on items count multiplied by averageItemHeight or real item height (from visible items). Example:
/// [ 10, 9, 8, 7, (6, 5, 4, 3), 2, 1, 0] - 6, 5, 4, 3 are visible and have know heights but others have unknown height and for them averageItemHeight will be used to calculate the whole content height
var topOffsetY: CGFloat = 0
var bottomOffsetY: CGFloat = 0
var virtualScrollOffsetY: CGFloat = 0
/// How much distance were overscolled on top which often means to show sticky scrolling that should scroll back to real position after a users finishes dragging the scrollView
var overscrolledTop: CGFloat = 0
/// Adds content padding to bottom and top
var inset: CGFloat = 100
/// Estimated height of the contents of scroll view
var height: CGFloat {
get { bottomOffsetY - topOffsetY }
}
/// Estimated height of the contents of scroll view + distance of overscrolled effect. It's only updated when number of item changes to prevent jumping of scroll bar
var virtualOverscrolledHeight: CGFloat {
get {
bottomOffsetY - topOffsetY + overscrolledTop - inset * 2
}
}
func update(
_ contentOffset: CGPoint,
_ listState: ListState,
_ averageItemHeight: CGFloat,
_ updateStaleHeight: Bool
) {
let lastVisible = listState.visibleItems.last
let firstVisible = listState.visibleItems.first
guard let last = lastVisible, let first = firstVisible else {
topOffsetY = contentOffset.y
bottomOffsetY = contentOffset.y
virtualScrollOffsetY = 0
overscrolledTop = 0
return
}
topOffsetY = last.view.frame.origin.y - CGFloat(listState.totalItemsCount - last.index - 1) * averageItemHeight - self.inset
bottomOffsetY = first.view.frame.origin.y + first.view.bounds.height + CGFloat(first.index) * averageItemHeight + self.inset
virtualScrollOffsetY = contentOffset.y - topOffsetY
overscrolledTop = max(0, last.index == listState.totalItemsCount - 1 ? last.view.frame.origin.y - contentOffset.y : 0)
}
}
var topY: CGFloat {
get { contentOffset.y }
}
var bottomY: CGFloat {
get { contentOffset.y + bounds.height }
}
override func layoutSubviews() {
super.layoutSubviews()
if contentSize.height == 0 {
setup()
}
let newScreenHeight = bounds.height
if newScreenHeight != oldScreenHeight && oldScreenHeight != 0 {
contentOffset.y += oldScreenHeight - newScreenHeight
scrollBarView.frame = CGRectMake(frame.width - 10, self.insetTop, 10, frame.height - self.insetTop - self.insetBottom)
}
oldScreenHeight = newScreenHeight
adaptItems(listState.items, false)
if let index = scrollToItemIndexDelayed {
scrollToItem(index)
scrollToItemIndexDelayed = nil
}
}
private func setup() {
contentSize = CGSizeMake(frame.size.width, initialOffset * 2)
prevProcessedOffset = initialOffset
contentOffset = CGPointMake(0, initialOffset)
showsVerticalScrollIndicator = false
scrollBarView.showsHorizontalScrollIndicator = false
panGestureRecognizer.delegate = self
addGestureRecognizer(scrollBarView.panGestureRecognizer)
superview!.addSubview(scrollBarView)
}
func updateItems(_ items: [ScrollItem], _ forceReloadVisible: Bool = false) {
if !Thread.isMainThread {
logger.error("Use main thread to update items")
return
}
if bounds.height == 0 {
self.listState.items = items
// this function requires to have valid bounds and it will be called again once it has them
return
}
adaptItems(items, forceReloadVisible)
snapToContent(animated: false)
}
/// [forceReloadVisible]: reloads every item that was visible regardless of hashValue changes
private func adaptItems(_ items: [ScrollItem], _ forceReloadVisible: Bool, overridenOffset: CGFloat? = nil) {
let start = Date.now
// special case when everything was removed
if items.isEmpty {
listState.visibleItems.forEach { item in item.view.removeFromSuperview() }
listState.visibleItems = []
listState.itemsCanCoverScreen = false
listState.firstVisibleItemId = EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
listState.firstVisibleItemIndex = 0
listState.firstVisibleItemOffset = -insetTop
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
scrollBarView.contentSize = .zero
scrollBarView.contentOffset = .zero
prevProcessedOffset = contentOffset.y
// this check is just to prevent didSet listener from firing on the same empty array, no use for this
if !self.listState.items.isEmpty {
self.listState.items = items
}
return
}
let contentOffsetY = overridenOffset ?? contentOffset.y
var oldVisible = listState.visibleItems
var newVisible: [VisibleItem] = []
var visibleItemsHeight: CGFloat = 0
let offsetsDiff = contentOffsetY - prevProcessedOffset
var shouldBeFirstVisible = items.firstIndex(where: { item in item.id == listState.firstVisibleItemId as! ScrollItem.ID }) ?? 0
var wasFirstVisibleItemOffset = listState.firstVisibleItemOffset
var alreadyChangedIndexWhileScrolling = false
var allowOneMore = false
var nextOffsetY: CGFloat = 0
var i = shouldBeFirstVisible
// building list of visible items starting from the first one that should be visible
while i >= 0 && i < items.count {
let item = items[i]
let visibleIndex = oldVisible.firstIndex(where: { vis in vis.item.id == item.id })
let visible: VisibleItem?
if let visibleIndex {
let v = oldVisible.remove(at: visibleIndex)
if forceReloadVisible || v.view.bounds.width != bounds.width || v.item.hashValue != item.hashValue {
let wasHeight = v.view.bounds.height
updateCell(v.view, i, items)
if wasHeight < v.view.bounds.height && i == 0 && shouldBeFirstVisible == i {
v.view.frame.origin.y -= v.view.bounds.height - wasHeight
}
}
visible = v
} else {
visible = nil
}
if shouldBeFirstVisible == i {
if let vis = visible {
if // there is auto scroll in progress and the first item has a higher offset than bottom part
// of the screen. In order to make scrolling down & up equal in time, we treat this as a sign to
// re-make the first visible item
(listState.isAnimatedScrolling && vis.view.frame.origin.y + vis.view.bounds.height < contentOffsetY + bounds.height) ||
// the fist visible item previously is hidden now, remove it and move on
!isVisible(vis.view) {
let newIndex: Int
if listState.isAnimatedScrolling {
// skip many items to make the scrolling take less time
var indexDiff = !alreadyChangedIndexWhileScrolling ? Int(ceil(abs(offsetsDiff / averageItemHeight))) : 0
// if index was already changed, no need to change it again. Otherwise, the scroll will overscoll and return back animated. Because it means the whole screen was scrolled
alreadyChangedIndexWhileScrolling = true
indexDiff = offsetsDiff <= 0 ? indexDiff : -indexDiff
newIndex = max(0, min(items.count - 1, i + indexDiff))
// offset for the first visible item can now be 0 because the previous first visible item doesn't exist anymore
wasFirstVisibleItemOffset = 0
} else {
// don't skip multiple items if it's manual scrolling gesture
newIndex = i + (offsetsDiff <= 0 ? 1 : -1)
}
shouldBeFirstVisible = newIndex
i = newIndex
cellsToReuse.append(vis.view)
hideAndRemoveFromSuperviewIfNeeded(vis.view)
continue
}
}
let vis: VisibleItem
if let visible {
vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view))
} else {
let cell = createCell(i, items, &cellsToReuse)!
cell.frame.origin.y = bottomY + wasFirstVisibleItemOffset - cell.frame.height
vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell))
}
if vis.view.superview == nil {
addSubview(vis.view)
}
newVisible.append(vis)
visibleItemsHeight += vis.view.frame.height
nextOffsetY = vis.view.frame.origin.y
} else {
let vis: VisibleItem
if let visible {
vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view))
nextOffsetY -= vis.view.frame.height
vis.view.frame.origin.y = nextOffsetY
} else {
let cell = createCell(i, items, &cellsToReuse)!
nextOffsetY -= cell.frame.height
cell.frame.origin.y = nextOffsetY
vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell))
}
if vis.view.superview == nil {
addSubview(vis.view)
}
newVisible.append(vis)
visibleItemsHeight += vis.view.frame.height
}
if abs(nextOffsetY) < contentOffsetY && !allowOneMore {
break
} else if abs(nextOffsetY) < contentOffsetY {
allowOneMore = false
}
i += 1
}
if let firstVisible = newVisible.first, firstVisible.view.frame.origin.y + firstVisible.view.frame.height < contentOffsetY + bounds.height, firstVisible.index > 0 {
var offset: CGFloat = firstVisible.view.frame.origin.y + firstVisible.view.frame.height
let index = firstVisible.index
for i in stride(from: index - 1, through: 0, by: -1) {
let item = items[i]
let visibleIndex = oldVisible.firstIndex(where: { vis in vis.item.id == item.id })
let vis: VisibleItem
if let visibleIndex {
let visible = oldVisible.remove(at: visibleIndex)
visible.view.frame.origin.y = offset
vis = VisibleItem(index: i, item: item, view: visible.view, offset: offsetToBottom(visible.view))
} else {
let cell = createCell(i, items, &cellsToReuse)!
cell.frame.origin.y = offset
vis = VisibleItem(index: i, item: item, view: cell, offset: offsetToBottom(cell))
}
if vis.view.superview == nil {
addSubview(vis.view)
}
offset += vis.view.frame.height
newVisible.insert(vis, at: 0)
visibleItemsHeight += vis.view.frame.height
if offset >= contentOffsetY + bounds.height {
break
}
}
}
// removing already unneeded visible items
oldVisible.forEach { vis in
cellsToReuse.append(vis.view)
hideAndRemoveFromSuperviewIfNeeded(vis.view)
}
let itemsCountChanged = listState.items.count != items.count
prevProcessedOffset = contentOffsetY
listState.visibleItems = newVisible
// bottom drawing starts from 0 until top visible area at least (bound.height - insetTop) or above top bar (bounds.height).
// For visible items to preserve offset after adding more items having such height is enough
listState.itemsCanCoverScreen = visibleItemsHeight >= bounds.height - insetTop
listState.firstVisibleItemId = listState.visibleItems.first?.item.id ?? EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
listState.firstVisibleItemIndex = listState.visibleItems.first?.index ?? 0
listState.firstVisibleItemOffset = listState.visibleItems.first?.offset ?? -insetTop
// updating the items with the last step in order to call listener with fully updated state
listState.items = items
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, itemsCountChanged)
scrollBarView.contentSize = CGSizeMake(bounds.width, estimatedContentHeight.virtualOverscrolledHeight)
scrollBarView.contentOffset = CGPointMake(0, estimatedContentHeight.virtualScrollOffsetY)
scrollBarView.isHidden = listState.visibleItems.count == listState.items.count && (listState.visibleItems.isEmpty || -listState.firstVisibleItemOffset + (listState.visibleItems.last?.offset ?? 0) + insetTop < bounds.height)
if debug {
println("time spent \((-start.timeIntervalSinceNow).description.prefix(5).replacingOccurrences(of: "0.000", with: "<0").replacingOccurrences(of: "0.", with: ""))")
}
}
func setScrollPosition(_ index: Int, _ id: Int64, _ offset: CGFloat = 0) {
listState.firstVisibleItemIndex = index
listState.firstVisibleItemId = id
listState.firstVisibleItemOffset = offset == 0 ? -bounds.height + insetTop + insetBottom : offset
}
func scrollToItem(_ index: Int, top: Bool = true) {
if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling {
return
}
if bounds.height == 0 || contentSize.height == 0 {
scrollToItemIndexDelayed = index
return
}
listState.isScrolling = true
defer {
listState.isScrolling = false
}
// just a faster way to set top item as requested index
listState.firstVisibleItemIndex = index
listState.firstVisibleItemId = listState.items[index].id
listState.firstVisibleItemOffset = -bounds.height + insetTop + insetBottom
scrollBarView.flashScrollIndicators()
adaptItems(listState.items, false)
var adjustedOffset = self.contentOffset.y
var i = 0
var upPrev = index > listState.firstVisibleItemIndex
//let firstOrLastIndex = upPrev ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
//let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier)
var stepSlowdownMultiplier: CGFloat = 1
while i < 200 {
let up = index > listState.firstVisibleItemIndex
if upPrev != up {
stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5
upPrev = up
}
// these two lines makes scrolling's finish non-linear and NOT overscroll visually when reach target index
let firstOrLastIndex = up ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) * stepSlowdownMultiplier
let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step
adjustedOffset += offsetToScroll
if let item = listState.visibleItems.first(where: { $0.index == index }) {
let y = if top {
min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop)
} else {
max(estimatedContentHeight.topOffsetY - insetTop - insetBottom, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom)
}
setContentOffset(CGPointMake(contentOffset.x, y), animated: false)
scrollBarView.flashScrollIndicators()
break
}
contentOffset = CGPointMake(contentOffset.x, adjustedOffset)
adaptItems(listState.items, false)
snapToContent(animated: false)
i += 1
}
adaptItems(listState.items, false)
snapToContent(animated: false)
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
}
func scrollToItemAnimated(_ index: Int, top: Bool = true) async {
if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling {
return
}
listState.isAnimatedScrolling = true
defer {
listState.isAnimatedScrolling = false
}
var adjustedOffset = self.contentOffset.y
var i = 0
var upPrev = index > listState.firstVisibleItemIndex
//let firstOrLastIndex = upPrev ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
//let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier)
var stepSlowdownMultiplier: CGFloat = 1
while i < 200 {
let up = index > listState.firstVisibleItemIndex
if upPrev != up {
stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5
upPrev = up
}
// these two lines makes scrolling's finish non-linear and NOT overscroll visually when reach target index
let firstOrLastIndex = up ? listState.visibleItems.last?.index ?? 0 : listState.firstVisibleItemIndex
let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) * stepSlowdownMultiplier
//println("Scrolling step \(step) \(stepSlowdownMultiplier) index \(index) \(firstOrLastIndex) \(index - firstOrLastIndex) \(adjustedOffset), up \(up), i \(i)")
let offsetToScroll = (up ? -averageItemHeight : averageItemHeight) * step
adjustedOffset += offsetToScroll
if let item = listState.visibleItems.first(where: { $0.index == index }) {
let y = if top {
min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop)
} else {
max(estimatedContentHeight.topOffsetY - insetTop - insetBottom, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom)
}
setContentOffset(CGPointMake(contentOffset.x, y), animated: true)
scrollBarView.flashScrollIndicators()
break
}
contentOffset = CGPointMake(contentOffset.x, adjustedOffset)
// skipping unneded relayout if this offset is already processed
if prevProcessedOffset - contentOffset.y != 0 {
adaptItems(listState.items, false)
snapToContent(animated: false)
}
// let UI time to update to see the animated position change
await MainActor.run {}
i += 1
}
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
}
func scrollToBottom() {
scrollToItem(0, top: false)
}
func scrollToBottomAnimated() {
Task {
await scrollToItemAnimated(0, top: false)
}
}
func scroll(by: CGFloat, animated: Bool = true) {
setContentOffset(CGPointMake(contentOffset.x, contentOffset.y + by), animated: animated)
}
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
if !listState.items.isEmpty {
scrollToBottomAnimated()
}
return false
}
private func snapToContent(animated: Bool) {
let topBlankSpace = estimatedContentHeight.height < bounds.height ? bounds.height - estimatedContentHeight.height : 0
if topY < estimatedContentHeight.topOffsetY - topBlankSpace {
setContentOffset(CGPointMake(0, estimatedContentHeight.topOffsetY - topBlankSpace), animated: animated)
} else if bottomY > estimatedContentHeight.bottomOffsetY {
setContentOffset(CGPointMake(0, estimatedContentHeight.bottomOffsetY - bounds.height), animated: animated)
}
}
func offsetToBottom(_ view: UIView) -> CGFloat {
bottomY - (view.frame.origin.y + view.frame.height)
}
/// If I try to .removeFromSuperview() right when I need to remove the view, it is possible to crash the app when the view was hidden in result of
/// pressing Hide in menu on top of the revealed item within the group. So at that point the item should still be attached to the view
func hideAndRemoveFromSuperviewIfNeeded(_ view: UIView) {
if view.isHidden {
// already passed this function
return
}
(view as? ReusableView)?.prepareForReuse()
view.isHidden = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
if view.isHidden { view.removeFromSuperview() }
}
}
/// Synchronizing both scrollViews
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
true
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if !decelerate {
snapToContent(animated: true)
}
}
override var contentOffset: CGPoint {
get { super.contentOffset }
set {
var newOffset = newValue
let topBlankSpace = estimatedContentHeight.height < bounds.height ? bounds.height - estimatedContentHeight.height : 0
if contentOffset.y > 0 && newOffset.y < estimatedContentHeight.topOffsetY - topBlankSpace && contentOffset.y > newOffset.y {
if !isDecelerating {
newOffset.y = min(contentOffset.y, newOffset.y + abs(newOffset.y - estimatedContentHeight.topOffsetY + topBlankSpace) / 1.8)
} else {
DispatchQueue.main.async {
self.setContentOffset(newValue, animated: false)
self.snapToContent(animated: true)
}
}
} else if contentOffset.y > 0 && newOffset.y + bounds.height > estimatedContentHeight.bottomOffsetY && contentOffset.y < newOffset.y {
if !isDecelerating {
newOffset.y = max(contentOffset.y, newOffset.y - abs(newOffset.y + bounds.height - estimatedContentHeight.bottomOffsetY) / 1.8)
} else {
DispatchQueue.main.async {
self.setContentOffset(newValue, animated: false)
self.snapToContent(animated: true)
}
}
}
super.contentOffset = newOffset
}
}
private func stopScrolling() {
let offsetYToStopAt = if abs(contentOffset.y - estimatedContentHeight.topOffsetY) < abs(bottomY - estimatedContentHeight.bottomOffsetY) {
estimatedContentHeight.topOffsetY
} else {
estimatedContentHeight.bottomOffsetY - bounds.height
}
setContentOffset(CGPointMake(contentOffset.x, offsetYToStopAt), animated: false)
}
func isVisible(_ view: UIView) -> Bool {
if view.superview == nil {
return false
}
return view.frame.intersects(CGRectMake(0, contentOffset.y, bounds.width, bounds.height))
}
}
private func println(_ text: String) {
print("\(Date.now.timeIntervalSince1970): \(text)")
}