mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
* Fixes #276 - Rebuilt room timeline: - Removed the need for the ListCollectionViewAdapter - Rewrote the TimelineItemList without using introspection - Added ReversedScrollView for laying out items at the bottom/trailing - Rewrote TimelineProvider diffing through CollectionDifference (similar to the RoomSummaryProvider) - Added back `scrollDismissesKeyboard` behavior - Various other tweaks and fixes - Fixed various warnings: - removed async AttributedStringBuilder as AttributedString is non-sendable, made the RoomTimelineItemFactory synchronous - removed unused virtual timeline items - removed unused isOutgoing property from the FormattedBodyText * Make TimelineItemContextMenuActions indentifiable and specify contextMenu identifiers * Bump the matrix-rust-components-swift to v1.0.16-alpha * Add changes file and changelog contribution guide * Fix attributed string builder unit tests
This commit is contained in:
@@ -1,223 +0,0 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
class ListCollectionViewAdapter: NSObject, UICollectionViewDelegate {
|
||||
private enum ContentOffsetDetails {
|
||||
case topOffset(previousVisibleIndexPath: IndexPath, previousItemCount: Int)
|
||||
case bottomOffset
|
||||
}
|
||||
|
||||
private let topDetectionOffset: CGFloat
|
||||
private let bottomDetectionOffset: CGFloat
|
||||
|
||||
private var contentOffsetObserverToken: NSKeyValueObservation?
|
||||
private var boundsObserverToken: NSKeyValueObservation?
|
||||
|
||||
private var offsetDetails: ContentOffsetDetails?
|
||||
private var draggingInitiated = false
|
||||
private var isAnimatingKeyboardAppearance = false
|
||||
private var previousFrame: CGRect = .zero
|
||||
|
||||
private(set) var collectionView: UICollectionView?
|
||||
|
||||
let scrollViewDidRestPublisher = PassthroughSubject<Void, Never>()
|
||||
let scrollViewTopVisiblePublisher = CurrentValueSubject<Bool, Never>(false)
|
||||
let scrollViewBottomVisiblePublisher = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
override init() {
|
||||
topDetectionOffset = 0.0
|
||||
bottomDetectionOffset = 0.0
|
||||
}
|
||||
|
||||
init(collectionView: UICollectionView, topDetectionOffset: CGFloat, bottomDetectionOffset: CGFloat) {
|
||||
self.collectionView = collectionView
|
||||
self.topDetectionOffset = topDetectionOffset
|
||||
self.bottomDetectionOffset = bottomDetectionOffset
|
||||
|
||||
super.init()
|
||||
|
||||
collectionView.clipsToBounds = true
|
||||
collectionView.keyboardDismissMode = .onDrag
|
||||
|
||||
registerContentOffsetObserver()
|
||||
registerBoundsObserver()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow(notification:)), name: UIResponder.keyboardDidShowNotification, object: nil)
|
||||
|
||||
collectionView.panGestureRecognizer.addTarget(self, action: #selector(handlePanGesture(_:)))
|
||||
}
|
||||
|
||||
func saveCurrentOffset() {
|
||||
guard let collectionView,
|
||||
collectionView.numberOfSections > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
if computeIsBottomVisible() {
|
||||
offsetDetails = .bottomOffset
|
||||
} else if computeIsTopVisible(), let topIndexPath = collectionView.indexPathsForVisibleItems.first {
|
||||
offsetDetails = .topOffset(previousVisibleIndexPath: topIndexPath,
|
||||
previousItemCount: collectionView.numberOfItems(inSection: 0))
|
||||
}
|
||||
}
|
||||
|
||||
func restoreSavedOffset() {
|
||||
defer {
|
||||
offsetDetails = nil
|
||||
}
|
||||
|
||||
guard let collectionView,
|
||||
collectionView.numberOfSections > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
let currentItemCount = collectionView.numberOfItems(inSection: 0)
|
||||
|
||||
switch offsetDetails {
|
||||
case .bottomOffset:
|
||||
collectionView.scrollToItem(at: .init(item: max(0, currentItemCount - 1), section: 0), at: .bottom, animated: false)
|
||||
case .topOffset(let indexPath, let previousItemCount):
|
||||
let item = indexPath.item + max(0, currentItemCount - previousItemCount)
|
||||
if item < currentItemCount {
|
||||
collectionView.scrollToItem(at: .init(item: item, section: 0), at: .top, animated: false)
|
||||
}
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var isTracking: Bool {
|
||||
collectionView?.isTracking == true
|
||||
}
|
||||
|
||||
var isDecelerating: Bool {
|
||||
collectionView?.isDecelerating == true
|
||||
}
|
||||
|
||||
func scrollToBottom(animated: Bool = false) {
|
||||
guard let collectionView,
|
||||
collectionView.numberOfSections > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
let currentItemCount = collectionView.numberOfItems(inSection: 0)
|
||||
guard currentItemCount > 1 else {
|
||||
return
|
||||
}
|
||||
|
||||
collectionView.scrollToItem(at: .init(item: currentItemCount - 1, section: 0), at: .bottom, animated: animated)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func registerContentOffsetObserver() {
|
||||
// Don't attempt stealing the UICollectionView delegate away from the List.
|
||||
// Doing so results in undefined behavior e.g. context menus not working
|
||||
contentOffsetObserverToken = collectionView?.observe(\.contentOffset, options: .new) { [weak self] _, _ in
|
||||
self?.handleScrollViewScroll()
|
||||
}
|
||||
}
|
||||
|
||||
private func deregisterContentOffsetObserver() {
|
||||
contentOffsetObserverToken?.invalidate()
|
||||
}
|
||||
|
||||
private func registerBoundsObserver() {
|
||||
boundsObserverToken = collectionView?.observe(\.frame, options: .new) { [weak self] collectionView, _ in
|
||||
self?.previousFrame = collectionView.frame
|
||||
self?.handleScrollViewScroll()
|
||||
}
|
||||
}
|
||||
|
||||
private func deregisterBoundsObserver() {
|
||||
boundsObserverToken?.invalidate()
|
||||
}
|
||||
|
||||
@objc private func keyboardWillShow(notification: NSNotification) {
|
||||
isAnimatingKeyboardAppearance = true
|
||||
}
|
||||
|
||||
@objc private func keyboardDidShow(notification: NSNotification) {
|
||||
isAnimatingKeyboardAppearance = false
|
||||
}
|
||||
|
||||
private func handleScrollViewScroll() {
|
||||
guard let collectionView else {
|
||||
return
|
||||
}
|
||||
|
||||
let hasScrolledBecauseOfFrameChange = (previousFrame != collectionView.frame)
|
||||
let shouldPinToBottom = scrollViewBottomVisiblePublisher.value && (isAnimatingKeyboardAppearance || hasScrolledBecauseOfFrameChange)
|
||||
|
||||
if shouldPinToBottom {
|
||||
deregisterContentOffsetObserver()
|
||||
scrollToBottom()
|
||||
DispatchQueue.main.async {
|
||||
self.registerContentOffsetObserver()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let isTopVisible = computeIsTopVisible()
|
||||
if isTopVisible != scrollViewTopVisiblePublisher.value {
|
||||
scrollViewTopVisiblePublisher.send(isTopVisible)
|
||||
}
|
||||
|
||||
let isBottomVisible = computeIsBottomVisible()
|
||||
if isBottomVisible != scrollViewBottomVisiblePublisher.value {
|
||||
scrollViewBottomVisiblePublisher.send(isBottomVisible)
|
||||
}
|
||||
|
||||
if !draggingInitiated, collectionView.isDragging {
|
||||
draggingInitiated = true
|
||||
} else if draggingInitiated, !collectionView.isDragging {
|
||||
draggingInitiated = false
|
||||
scrollViewDidRestPublisher.send(())
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handlePanGesture(_ sender: UIPanGestureRecognizer) {
|
||||
guard let collectionView,
|
||||
sender.state == .ended,
|
||||
draggingInitiated,
|
||||
!collectionView.isDecelerating else {
|
||||
return
|
||||
}
|
||||
|
||||
draggingInitiated = false
|
||||
scrollViewDidRestPublisher.send(())
|
||||
}
|
||||
|
||||
private func computeIsTopVisible() -> Bool {
|
||||
guard let scrollView = collectionView else {
|
||||
return false
|
||||
}
|
||||
|
||||
return (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) <= topDetectionOffset
|
||||
}
|
||||
|
||||
private func computeIsBottomVisible() -> Bool {
|
||||
guard let scrollView = collectionView else {
|
||||
return false
|
||||
}
|
||||
|
||||
return (scrollView.contentOffset.y + bottomDetectionOffset) >= (scrollView.contentSize.height - scrollView.frame.size.height)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ enum TimelineStyle: String, CaseIterable {
|
||||
case bubbles
|
||||
|
||||
/// List row insets for a timeline
|
||||
var listRowInsets: EdgeInsets {
|
||||
var rowInsets: EdgeInsets {
|
||||
switch self {
|
||||
case .plain:
|
||||
return EdgeInsets(top: 4, leading: 20, bottom: 4, trailing: 20)
|
||||
|
||||
@@ -25,9 +25,9 @@ struct EmoteRoomTimelineView: View {
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: "face.dashed").padding(.top, 1.0)
|
||||
if let attributedComponents = timelineItem.attributedComponents {
|
||||
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, attributedComponents: attributedComponents)
|
||||
FormattedBodyText(attributedComponents: attributedComponents)
|
||||
} else {
|
||||
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, text: timelineItem.text)
|
||||
FormattedBodyText(text: timelineItem.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct FormattedBodyText: View {
|
||||
#warning("this is a dirty fix for demo, should be refactored after new timeline api")
|
||||
let isOutgoing: Bool
|
||||
let attributedComponents: [AttributedStringBuilderComponent]
|
||||
|
||||
var body: some View {
|
||||
@@ -51,8 +49,7 @@ struct FormattedBodyText: View {
|
||||
}
|
||||
|
||||
extension FormattedBodyText {
|
||||
init(isOutgoing: Bool, text: String) {
|
||||
self.isOutgoing = isOutgoing
|
||||
init(text: String) {
|
||||
attributedComponents = [.init(attributedString: AttributedString(text), isBlockquote: false)]
|
||||
}
|
||||
}
|
||||
@@ -94,11 +91,11 @@ struct FormattedBodyText_Previews: PreviewProvider {
|
||||
let attributedString = attributedStringBuilder.fromHTML(htmlString)
|
||||
|
||||
if let components = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedString) {
|
||||
FormattedBodyText(isOutgoing: true, attributedComponents: components)
|
||||
FormattedBodyText(attributedComponents: components)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
FormattedBodyText(isOutgoing: true, text: "Some plain text that's not an attributed component.")
|
||||
FormattedBodyText(text: "Some plain text that's not an attributed component.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@ struct NoticeRoomTimelineView: View {
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: "exclamationmark.bubble").padding(.top, 2.0)
|
||||
if let attributedComponents = timelineItem.attributedComponents {
|
||||
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, attributedComponents: attributedComponents)
|
||||
FormattedBodyText(attributedComponents: attributedComponents)
|
||||
} else {
|
||||
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, text: timelineItem.text)
|
||||
FormattedBodyText(text: timelineItem.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ struct RedactedRoomTimelineView: View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, text: timelineItem.text)
|
||||
FormattedBodyText(text: timelineItem.text)
|
||||
}
|
||||
}
|
||||
.id(timelineItem.id)
|
||||
|
||||
@@ -23,9 +23,9 @@ struct TextRoomTimelineView: View {
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
if let attributedComponents = timelineItem.attributedComponents {
|
||||
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, attributedComponents: attributedComponents)
|
||||
FormattedBodyText(attributedComponents: attributedComponents)
|
||||
} else {
|
||||
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, text: timelineItem.text)
|
||||
FormattedBodyText(text: timelineItem.text)
|
||||
}
|
||||
}
|
||||
.id(timelineItem.id)
|
||||
|
||||
@@ -15,134 +15,167 @@
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Introspect
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineItemList: View {
|
||||
@State private var collectionViewObserver = ListCollectionViewAdapter()
|
||||
@State private var timelineItems: [RoomTimelineViewProvider] = []
|
||||
@State private var hasPendingChanges = false
|
||||
@ObservedObject private var settings = ElementSettings.shared
|
||||
|
||||
@State private var timelineItems: [RoomTimelineViewProvider] = []
|
||||
@State private var viewFrame: CGRect = .zero
|
||||
@State private var pinnedItem: PinnedItem?
|
||||
@State private var visibleItemIdentifiers: Set<String> = []
|
||||
@State private var topVisiblePublisher = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
@EnvironmentObject var context: RoomScreenViewModel.Context
|
||||
|
||||
let bottomVisiblePublisher: PassthroughSubject<Bool, Never>
|
||||
let bottomVisiblePublisher: CurrentValueSubject<Bool, Never>
|
||||
let scrollToBottomPublisher: PassthroughSubject<Void, Never>
|
||||
|
||||
@State private var viewFrame: CGRect = .zero
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
|
||||
.animation(.elementDefault, value: context.viewState.isBackPaginating)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
// timelineItems won't be set for static Xcode Previews until they're run.
|
||||
ForEach(isPreview ? context.viewState.items : timelineItems) { timelineItem in
|
||||
timelineItem
|
||||
.contextMenu {
|
||||
context.viewState.contextMenuBuilder?(timelineItem.id)
|
||||
ScrollViewReader { proxy in
|
||||
ReversedScrollView(.vertical) {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
|
||||
.animation(.elementDefault, value: context.viewState.isBackPaginating)
|
||||
|
||||
LazyVStack(spacing: 0.0) {
|
||||
ForEach(isRunningPreviews ? context.viewState.items : timelineItems) { item in
|
||||
item
|
||||
.contextMenu {
|
||||
context.viewState.contextMenuBuilder?(item.id)
|
||||
.id(item.id)
|
||||
}
|
||||
.opacity(opacityForItem(item))
|
||||
.padding(settings.timelineStyle.rowInsets)
|
||||
.onAppear {
|
||||
context.send(viewAction: .itemAppeared(id: item.id))
|
||||
visibleItemIdentifiers.insert(item.id)
|
||||
|
||||
if timelineItems.first == item {
|
||||
topVisiblePublisher.send(true)
|
||||
}
|
||||
|
||||
if timelineItems.last == item {
|
||||
bottomVisiblePublisher.send(true)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
context.send(viewAction: .itemDisappeared(id: item.id))
|
||||
visibleItemIdentifiers.remove(item.id)
|
||||
|
||||
if timelineItems.first == item {
|
||||
topVisiblePublisher.send(false)
|
||||
}
|
||||
|
||||
if timelineItems.last == item {
|
||||
bottomVisiblePublisher.send(false)
|
||||
}
|
||||
}
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
context.send(viewAction: .linkClicked(url: url))
|
||||
return .systemAction
|
||||
})
|
||||
}
|
||||
.opacity(opacityForItem(timelineItem))
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(settings.timelineStyle.listRowInsets)
|
||||
.onAppear {
|
||||
context.send(viewAction: .itemAppeared(id: timelineItem.id))
|
||||
}
|
||||
}
|
||||
.onChange(of: pinnedItem) { item in
|
||||
guard let item else {
|
||||
return
|
||||
}
|
||||
|
||||
if item.animated {
|
||||
withAnimation(Animation.elementDefault) {
|
||||
proxy.scrollTo(item.id, anchor: item.anchor)
|
||||
}
|
||||
.onDisappear {
|
||||
context.send(viewAction: .itemDisappeared(id: timelineItem.id))
|
||||
}
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
context.send(viewAction: .linkClicked(url: url))
|
||||
return .systemAction
|
||||
})
|
||||
} else {
|
||||
proxy.scrollTo(item.id, anchor: item.anchor)
|
||||
}
|
||||
|
||||
pinnedItem = nil
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.background(ViewFrameReader(frame: $viewFrame))
|
||||
.environment(\.timelineWidth, viewFrame.width)
|
||||
.timelineStyle(settings.timelineStyle)
|
||||
.environment(\.defaultMinListRowHeight, 0.0)
|
||||
.introspectCollectionView { collectionView in
|
||||
if collectionView == collectionViewObserver.collectionView { return }
|
||||
|
||||
collectionViewObserver = ListCollectionViewAdapter(collectionView: collectionView,
|
||||
topDetectionOffset: collectionView.bounds.size.height / 3.0,
|
||||
bottomDetectionOffset: 10.0)
|
||||
|
||||
collectionViewObserver.scrollToBottom()
|
||||
|
||||
// Check if there are enough items. Otherwise ask for more
|
||||
attemptBackPagination()
|
||||
}
|
||||
.onAppear {
|
||||
if timelineItems != context.viewState.items {
|
||||
timelineItems = context.viewState.items
|
||||
requestBackPagination()
|
||||
}
|
||||
// Allow SwiftUI to layout the views properly before checking if the top is visible
|
||||
.onReceive(topVisiblePublisher.collect(.byTime(DispatchQueue.main, 0.5))) { values in
|
||||
if values.last == true {
|
||||
requestBackPagination()
|
||||
}
|
||||
}
|
||||
.onReceive(scrollToBottomPublisher) {
|
||||
collectionViewObserver.scrollToBottom(animated: true)
|
||||
scrollToBottom(animated: true)
|
||||
}
|
||||
.onReceive(collectionViewObserver.scrollViewTopVisiblePublisher) { isTopVisible in
|
||||
if !isTopVisible || context.viewState.isBackPaginating {
|
||||
.onChange(of: context.viewState.items.count) { _ in
|
||||
guard !context.viewState.items.isEmpty,
|
||||
context.viewState.items.count != timelineItems.count else {
|
||||
return
|
||||
}
|
||||
|
||||
attemptBackPagination()
|
||||
}
|
||||
.onReceive(collectionViewObserver.scrollViewBottomVisiblePublisher) { isBottomVisible in
|
||||
bottomVisiblePublisher.send(isBottomVisible)
|
||||
}
|
||||
.onChange(of: context.viewState.items) { _ in
|
||||
// If the count hasn't changed then don't observe a pagination
|
||||
guard context.viewState.items.count != timelineItems.count else {
|
||||
// Pin to the bottom if empty
|
||||
if timelineItems.isEmpty {
|
||||
if let lastItem = context.viewState.items.last {
|
||||
let pinnedItem = PinnedItem(id: lastItem.id, anchor: .bottom, animated: false)
|
||||
timelineItems = context.viewState.items
|
||||
self.pinnedItem = pinnedItem
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Pin to the new bottom if visible
|
||||
if let currentLastItem = timelineItems.last,
|
||||
visibleItemIdentifiers.contains(currentLastItem.id),
|
||||
let newLastItem = context.viewState.items.last {
|
||||
let pinnedItem = PinnedItem(id: newLastItem.id, anchor: .bottom, animated: false)
|
||||
timelineItems = context.viewState.items
|
||||
self.pinnedItem = pinnedItem
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Don't update the list while moving
|
||||
if collectionViewObserver.isDecelerating || collectionViewObserver.isTracking {
|
||||
hasPendingChanges = true
|
||||
// Pin to the old topmost visible
|
||||
if let currentFirstItem = timelineItems.first,
|
||||
visibleItemIdentifiers.contains(currentFirstItem.id) {
|
||||
let pinnedItem = PinnedItem(id: currentFirstItem.id, anchor: .top, animated: false)
|
||||
timelineItems = context.viewState.items
|
||||
self.pinnedItem = pinnedItem
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
collectionViewObserver.saveCurrentOffset()
|
||||
// Otherwise just update the items
|
||||
timelineItems = context.viewState.items
|
||||
}
|
||||
.onReceive(collectionViewObserver.scrollViewDidRestPublisher) {
|
||||
if hasPendingChanges == false {
|
||||
.background(GeometryReader { geo in
|
||||
Color.clear.preference(key: ViewFramePreferenceKey.self, value: [geo.frame(in: .global)])
|
||||
})
|
||||
.onPreferenceChange(ViewFramePreferenceKey.self) { _ in
|
||||
guard bottomVisiblePublisher.value == true else {
|
||||
return
|
||||
}
|
||||
|
||||
collectionViewObserver.saveCurrentOffset()
|
||||
timelineItems = context.viewState.items
|
||||
hasPendingChanges = false
|
||||
}
|
||||
.onChange(of: timelineItems.count) { _ in
|
||||
collectionViewObserver.restoreSavedOffset()
|
||||
|
||||
// Check if there are enough items. Otherwise ask for more
|
||||
attemptBackPagination()
|
||||
// Pin the timeline to the bottom if was there on the frame change
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
scrollToBottom(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scrollToBottom(animated: Bool = false) {
|
||||
collectionViewObserver.scrollToBottom(animated: animated)
|
||||
// MARK: - Private
|
||||
|
||||
private func scrollToBottom(animated: Bool = false) {
|
||||
if let lastItem = timelineItems.last {
|
||||
pinnedItem = PinnedItem(id: lastItem.id, anchor: .bottom, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
private func attemptBackPagination() {
|
||||
if context.viewState.isBackPaginating {
|
||||
return
|
||||
}
|
||||
|
||||
if collectionViewObserver.scrollViewTopVisiblePublisher.value == false {
|
||||
return
|
||||
}
|
||||
|
||||
private func requestBackPagination() {
|
||||
context.send(viewAction: .loadPreviousPage)
|
||||
}
|
||||
|
||||
@@ -154,7 +187,7 @@ struct TimelineItemList: View {
|
||||
return selectedItemId == item.id ? 1.0 : 0.5
|
||||
}
|
||||
|
||||
private var isPreview: Bool {
|
||||
private var isRunningPreviews: Bool {
|
||||
#if DEBUG
|
||||
return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||
#else
|
||||
@@ -163,6 +196,20 @@ struct TimelineItemList: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct PinnedItem: Equatable {
|
||||
let id: String
|
||||
let anchor: UnitPoint
|
||||
let animated: Bool
|
||||
}
|
||||
|
||||
private struct ViewFramePreferenceKey: PreferenceKey {
|
||||
static var defaultValue = [CGRect]() // Doesn't work with plain CGRects
|
||||
|
||||
static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) {
|
||||
value += nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineItemList_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
body.preferredColorScheme(.light)
|
||||
@@ -176,7 +223,7 @@ struct TimelineItemList_Previews: PreviewProvider {
|
||||
mediaProvider: MockMediaProvider(),
|
||||
roomName: nil)
|
||||
|
||||
TimelineItemList(bottomVisiblePublisher: PassthroughSubject(), scrollToBottomPublisher: PassthroughSubject())
|
||||
TimelineItemList(bottomVisiblePublisher: CurrentValueSubject(false), scrollToBottomPublisher: PassthroughSubject())
|
||||
.environmentObject(viewModel.context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import SwiftUI
|
||||
import Introspect
|
||||
|
||||
struct TimelineView: View {
|
||||
@State private var bottomVisiblePublisher = PassthroughSubject<Bool, Never>()
|
||||
@State private var bottomVisiblePublisher = CurrentValueSubject<Bool, Never>(true)
|
||||
@State private var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
|
||||
@State private var scollToBottomButtonVisible = false
|
||||
|
||||
|
||||
Reference in New Issue
Block a user