New timeline (#276) (#280)

* 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:
Stefan Ceriu
2022-11-02 13:03:34 +02:00
committed by GitHub
parent b270b8a30e
commit fabb0bc95f
31 changed files with 516 additions and 584 deletions
@@ -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