Implement message sending. Refactor the timeline. Fix various UI/UX issues and add scroll to bottom timeline button.

This commit is contained in:
Stefan Ceriu
2022-04-18 10:54:48 +03:00
parent 2e92653c9d
commit 7f1c24059f
34 changed files with 802 additions and 262 deletions
+3 -1
View File
@@ -26,7 +26,9 @@ internal enum Asset {
internal static let elementGreen = ColorAsset(name: "Colors/ElementGreen")
}
internal enum Images {
internal static let appLogo = ImageAsset(name: "Images/app-logo")
internal static let appLogo = ImageAsset(name: "Images/appLogo")
internal static let timelineComposerSendMessage = ImageAsset(name: "Images/timelineComposerSendMessage")
internal static let timelineScrollToBottom = ImageAsset(name: "Images/timelineScrollToBottom")
}
}
// swiftlint:enable identifier_name line_length nesting type_body_length type_name
@@ -25,10 +25,20 @@ enum RoomScreenViewAction {
case itemAppeared(id: String)
case itemDisappeared(id: String)
case linkClicked(url: URL)
case sendMessage
}
struct RoomScreenViewState: BindableState {
var roomTitle: String = ""
var items: [RoomTimelineViewProvider] = []
var isBackPaginating = false
var bindings: RoomScreenViewStateBindings
var sendButtonDisabled: Bool {
bindings.composerText.count == 0
}
}
struct RoomScreenViewStateBindings {
var composerText: String
}
@@ -38,7 +38,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
self.timelineController = timelineController
self.timelineViewFactory = timelineViewFactory
super.init(initialViewState: RoomScreenViewState(roomTitle: roomName ?? "Unknown room 💥"))
super.init(initialViewState: RoomScreenViewState(roomTitle: roomName ?? "Unknown room 💥", bindings: RoomScreenViewStateBindings(composerText: "")))
timelineController.callbacks.sink { [weak self] callback in
guard let self = self else { return }
@@ -74,6 +74,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
timelineController.processItemDisappearance(id)
case .linkClicked(let url):
MXLog.warning("Link clicked: \(url)")
case .sendMessage:
guard state.bindings.composerText.count > 0 else {
return
}
timelineController.sendMessage(state.bindings.composerText)
state.bindings.composerText = ""
}
}
@@ -0,0 +1,221 @@
//
// ListTableViewAdapter.swift
// ElementX
//
// Created by Stefan Ceriu on 15/04/2022.
// Copyright © 2022 element.io. All rights reserved.
//
import UIKit
import Combine
class ListTableViewAdapter: NSObject, UITableViewDelegate {
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 isAtTop: Bool = false
private var isAtBottom: Bool = false
private var offsetDetails: ContentOffsetDetails?
private var draggingInitiated = false
private var isAnimatingKeyboardAppearance = false
private var previousFrame: CGRect = .zero
private(set) var tableView: UITableView?
let scrollViewDidRestPublisher = PassthroughSubject<Void, Never>()
let scrollViewDidReachTopPublisher = PassthroughSubject<Void, Never>()
let scrollViewBottomVisiblePublisher = PassthroughSubject<Bool, Never>()
override init() {
self.topDetectionOffset = 0.0
self.bottomDetectionOffset = 0.0
}
init(tableView: UITableView, topDetectionOffset: CGFloat, bottomDetectionOffset: CGFloat) {
self.tableView = tableView
self.topDetectionOffset = topDetectionOffset
self.bottomDetectionOffset = bottomDetectionOffset
super.init()
tableView.keyboardDismissMode = .onDrag
registerContentOfffsetObserver()
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)
tableView.panGestureRecognizer.addTarget(self, action: #selector(handlePanGesture(_:)))
}
func saveCurrentOffset() {
guard let tableView = tableView,
tableView.numberOfSections > 0 else {
return
}
if isBottomVisible {
offsetDetails = .bottomOffset
} else if isTopVisible {
if let topIndexPath = tableView.indexPathsForVisibleRows?.first {
offsetDetails = .topOffset(previousVisibleIndexPath: topIndexPath,
previousItemCount: tableView.numberOfRows(inSection: 0))
}
}
}
func restoreSavedOffset() {
defer {
offsetDetails = nil
}
guard let tableView = tableView,
tableView.numberOfSections > 0 else {
return
}
let currentItemCount = tableView.numberOfRows(inSection: 0)
switch offsetDetails {
case .bottomOffset:
tableView.scrollToRow(at: .init(row: max(0, currentItemCount - 1), section: 0), at: .bottom, animated: false)
case .topOffset(let indexPath, let previousItemCount):
let row = indexPath.row + max(0, (currentItemCount - previousItemCount))
if row < currentItemCount {
tableView.scrollToRow(at: .init(row: row, section: 0), at: .top, animated: false)
}
case .none:
break
}
}
var isTracking: Bool {
self.tableView?.isTracking == true
}
var isDecelerating: Bool {
self.tableView?.isDecelerating == true
}
var isTopVisible: Bool {
guard let scrollView = tableView else {
return false
}
return (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) <= topDetectionOffset
}
var isBottomVisible: Bool {
guard let scrollView = tableView else {
return false
}
return (scrollView.contentOffset.y + self.bottomDetectionOffset) >= (scrollView.contentSize.height - scrollView.frame.size.height)
}
func scrollToBottom(animated: Bool = false) {
guard let tableView = tableView,
tableView.numberOfSections > 0 else {
return
}
let currentItemCount = tableView.numberOfRows(inSection: 0)
guard currentItemCount > 1 else {
return
}
tableView.scrollToRow(at: .init(row: currentItemCount - 1, section: 0), at: .bottom, animated: animated)
}
// MARK: - Private
private func registerContentOfffsetObserver() {
// Don't attempt stealing the UITableView delegate away from the List.
// Doing so results in undefined behavior e.g. context menus not working
contentOffsetObserverToken = tableView?.observe(\.contentOffset, options: .new, changeHandler: { [weak self] _, _ in
self?.handleScrollViewScroll()
})
}
private func deregisterContentOffsetObserver() {
contentOffsetObserverToken?.invalidate()
}
private func registerBoundsObserver() {
boundsObserverToken = tableView?.observe(\.frame, options: .new, changeHandler: { [weak self] tableView, _ in
self?.previousFrame = tableView.frame
})
}
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 tableView = self.tableView else {
return
}
let hasScrolledBecauseOfFrameChange = (previousFrame != tableView.frame)
let shouldPinToBottom = isAtBottom && (isAnimatingKeyboardAppearance || hasScrolledBecauseOfFrameChange)
if shouldPinToBottom {
deregisterContentOffsetObserver()
scrollToBottom()
DispatchQueue.main.async {
self.registerContentOfffsetObserver()
}
return
}
let isTopVisible = self.isTopVisible
if isTopVisible && self.isAtTop != isTopVisible {
self.scrollViewDidReachTopPublisher.send(())
}
self.isAtTop = isTopVisible
let isBottomVisible = self.isBottomVisible
if self.isAtBottom != isBottomVisible {
self.scrollViewBottomVisiblePublisher.send(isBottomVisible)
self.isAtBottom = isBottomVisible
}
if !self.draggingInitiated && tableView.isDragging {
self.draggingInitiated = true
} else if self.draggingInitiated && !tableView.isDragging {
self.draggingInitiated = false
self.scrollViewDidRestPublisher.send(())
}
}
@objc private func handlePanGesture(_ sender: UIPanGestureRecognizer) {
guard let tableView = self.tableView,
sender.state == .ended,
draggingInitiated == true,
!tableView.isDecelerating else {
return
}
self.draggingInitiated = false
self.scrollViewDidRestPublisher.send(())
}
}
@@ -0,0 +1,47 @@
//
// MessageComposer.swift
// ElementX
//
// Created by Stefan Ceriu on 15/04/2022.
// Copyright © 2022 element.io. All rights reserved.
//
import SwiftUI
struct MessageComposer: View {
@Binding var text: String
var disabled: Bool
let action: () -> Void
var body: some View {
HStack(alignment: .bottom) {
MessageComposerTextField(placeholder: "Send a message", text: $text, maxHeight: 300)
Button {
action()
} label: {
Image(uiImage: Asset.Images.timelineComposerSendMessage.image)
}
.padding(.bottom, 6.0)
.disabled(disabled)
.opacity(disabled ? 0.5 : 1.0)
.animation(.default, value: disabled)
.keyboardShortcut(.return, modifiers: [.command])
}
}
}
struct MessageComposer_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
@ViewBuilder
static var body: some View {
VStack {
MessageComposer(text: .constant(""), disabled: true) { }
MessageComposer(text: .constant("Some message"), disabled: false) { }
}
}
}
@@ -0,0 +1,182 @@
//
// MessageComposerTextField.swift
// ElementX
//
// Created by Stefan Ceriu on 15/04/2022.
// Copyright © 2022 element.io. All rights reserved.
//
import SwiftUI
struct MessageComposerTextField: View {
@Binding private var text: String
@State private var dynamicHeight: CGFloat = 100
@State private var isEditing = false
private let placeholder: String
private let maxHeight: CGFloat
private var showingPlaceholder: Bool {
text.isEmpty
}
init(placeholder: String, text: Binding<String>, maxHeight: CGFloat) {
self.placeholder = placeholder
self._text = text
self.maxHeight = maxHeight
}
private var placeholderColor: Color {
.gray
}
private var borderColor: Color {
Color(uiColor: Asset.Colors.elementGreen.color)
}
private var borderWidth: CGFloat {
return isEditing ? 2.0 : 1.0
}
var body: some View {
let rect = RoundedRectangle(cornerRadius: 8.0)
return UITextViewWrapper(text: $text,
calculatedHeight: $dynamicHeight,
isEditing: $isEditing,
maxHeight: maxHeight)
.frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
.padding(4.0)
.background(placeholderView, alignment: .topLeading)
.clipShape(rect)
.overlay(rect.stroke(borderColor, lineWidth: borderWidth))
}
@ViewBuilder
private var placeholderView: some View {
if showingPlaceholder {
Text(placeholder)
.foregroundColor(placeholderColor)
.padding(.leading, 8.0)
.padding(.top, 12.0)
}
}
}
@available(iOS 14.0, *)
private struct UITextViewWrapper: UIViewRepresentable {
typealias UIViewType = UITextView
@Binding var text: String
@Binding var calculatedHeight: CGFloat
@Binding var isEditing: Bool
let maxHeight: CGFloat
func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.isEditable = true
textView.font = UIFont.preferredFont(forTextStyle: .body)
textView.isSelectable = true
textView.isUserInteractionEnabled = true
textView.backgroundColor = UIColor.clear
textView.returnKeyType = .default
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return textView
}
func updateUIView(_ view: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
if view.text != self.text {
view.text = self.text
}
UITextViewWrapper.recalculateHeight(view: view, result: $calculatedHeight, maxHeight: maxHeight)
}
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text,
height: $calculatedHeight,
isEditing: $isEditing,
maxHeight: maxHeight)
}
fileprivate static func recalculateHeight(view: UIView, result: Binding<CGFloat>, maxHeight: CGFloat) {
let newSize = view.sizeThatFits(CGSize(width: view.frame.size.width, height: CGFloat.greatestFiniteMagnitude))
let height = min(maxHeight, newSize.height)
if result.wrappedValue != height {
DispatchQueue.main.async {
result.wrappedValue = height // !! must be called asynchronously
}
}
}
final class Coordinator: NSObject, UITextViewDelegate {
var text: Binding<String>
var calculatedHeight: Binding<CGFloat>
var isEditing: Binding<Bool>
let maxHeight: CGFloat
init(text: Binding<String>, height: Binding<CGFloat>, isEditing: Binding<Bool>, maxHeight: CGFloat) {
self.text = text
self.calculatedHeight = height
self.isEditing = isEditing
self.maxHeight = maxHeight
}
func textViewDidChange(_ uiView: UITextView) {
text.wrappedValue = uiView.text
UITextViewWrapper.recalculateHeight(view: uiView,
result: calculatedHeight,
maxHeight: maxHeight)
}
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
return true
}
func textViewDidBeginEditing(_ textView: UITextView) {
isEditing.wrappedValue = true
}
func textViewDidEndEditing(_ textView: UITextView) {
isEditing.wrappedValue = false
}
}
}
struct MessageComposerTextField_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
@ViewBuilder
static var body: some View {
VStack {
PreviewWrapper()
PlaceholderPreviewWrapper()
}
}
struct PreviewWrapper: View {
@State(initialValue: "123") var text: String
var body: some View {
MessageComposerTextField(placeholder: "Placeholder", text: $text, maxHeight: 300)
}
}
struct PlaceholderPreviewWrapper: View {
@State(initialValue: "") var text: String
var body: some View {
MessageComposerTextField(placeholder: "Placeholder", text: $text, maxHeight: 300)
}
}
}
@@ -21,9 +21,23 @@ struct RoomScreen: View {
@ObservedObject var context: RoomScreenViewModel.Context
var body: some View {
TimelineView(context: context)
.navigationTitle(context.viewState.roomTitle)
.navigationBarTitleDisplayMode(.inline)
VStack(spacing: 0.0) {
TimelineView(context: context)
.navigationTitle(context.viewState.roomTitle)
.navigationBarTitleDisplayMode(.inline)
MessageComposer(text: $context.composerText, disabled: context.viewState.sendButtonDisabled) {
sendMessage()
}
.padding()
}
}
private func sendMessage() {
guard !context.viewState.sendButtonDisabled else {
return
}
context.send(viewAction: .sendMessage)
}
}
@@ -31,6 +45,12 @@ struct RoomScreen: View {
struct RoomScreen_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
@ViewBuilder
static var body: some View {
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
roomName: "Preview room")
@@ -0,0 +1,152 @@
//
// TimelineItemList.swift
// ElementX
//
// Created by Stefan Ceriu on 15/04/2022.
// Copyright © 2022 element.io. All rights reserved.
//
import SwiftUI
import Combine
import Introspect
struct TimelineItemList: View {
@State private var tableViewObserver: ListTableViewAdapter = ListTableViewAdapter()
@State private var timelineItems: [RoomTimelineViewProvider] = []
@State private var hasPendingChanges = false
@ObservedObject var context: RoomScreenViewModel.Context
let bottomVisiblePublisher: PassthroughSubject<Bool, Never>
let scrollToBottomPublisher: PassthroughSubject<Void, Never>
var body: some View {
// The observer behaves differently when not in an reader
ScrollViewReader { _ in
List {
HStack {
Spacer()
ProgressView()
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
.animation(.default, value: context.viewState.isBackPaginating)
Spacer()
}
// No idea why previews don't work otherwise
ForEach(isPreview ? context.viewState.items : timelineItems) { timelineItem in
timelineItem
.listRowSeparator(.hidden)
.onAppear {
context.send(viewAction: .itemAppeared(id: timelineItem.id))
}
.onDisappear {
context.send(viewAction: .itemDisappeared(id: timelineItem.id))
}
.environment(\.openURL, OpenURLAction { url in
context.send(viewAction: .linkClicked(url: url))
return .systemAction
})
}
}
.listStyle(.plain)
.environment(\.defaultMinListRowHeight, 0.0)
.introspectTableView { tableView in
if tableView == tableViewObserver.tableView {
return
}
tableViewObserver = ListTableViewAdapter(tableView: tableView,
topDetectionOffset: (tableView.bounds.size.height / 3.0),
bottomDetectionOffset: 10.0)
tableViewObserver.scrollToBottom()
// Check if there are enough items. Otherwise ask for more
attemptBackPagination()
}
.onAppear(perform: {
if timelineItems != context.viewState.items {
timelineItems = context.viewState.items
}
})
.onReceive(scrollToBottomPublisher, perform: {
tableViewObserver.scrollToBottom(animated: true)
})
.onReceive(tableViewObserver.scrollViewBottomVisiblePublisher, perform: { value in
bottomVisiblePublisher.send(value)
})
.onReceive(tableViewObserver.scrollViewDidReachTopPublisher, perform: {
if context.viewState.isBackPaginating {
return
}
attemptBackPagination()
})
.onChange(of: context.viewState.items) { _ in
// Don't update the list while moving
if tableViewObserver.isDecelerating || tableViewObserver.isTracking {
hasPendingChanges = true
return
}
tableViewObserver.saveCurrentOffset()
timelineItems = context.viewState.items
}
.onReceive(tableViewObserver.scrollViewDidRestPublisher, perform: {
if hasPendingChanges == false {
return
}
tableViewObserver.saveCurrentOffset()
timelineItems = context.viewState.items
hasPendingChanges = false
})
.onChange(of: timelineItems, perform: { _ in
tableViewObserver.restoreSavedOffset()
// Check if there are enough items. Otherwise ask for more
attemptBackPagination()
})
}
}
func scrollToBottom(animated: Bool = false) {
tableViewObserver.scrollToBottom(animated: animated)
}
private func attemptBackPagination() {
if context.viewState.isBackPaginating {
return
}
if tableViewObserver.isTopVisible == false {
return
}
context.send(viewAction: .loadPreviousPage)
}
private var isPreview: Bool {
#if DEBUG
return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
#else
return false
#endif
}
}
struct TimelineItemList_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
@ViewBuilder
static var body: some View {
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
roomName: nil)
TimelineItemList(context: viewModel.context, bottomVisiblePublisher: PassthroughSubject(), scrollToBottomPublisher: PassthroughSubject())
}
}
@@ -14,264 +14,48 @@ import Introspect
struct TimelineView: View {
@State private var tableViewObserver: TableViewObserver = TableViewObserver()
@State private var timelineItems: [RoomTimelineViewProvider] = []
@State private var hasPendingChanges = false
@State private var text: String = ""
@State private var bottomVisiblePublisher = PassthroughSubject<Bool, Never>()
@State private var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
@State private var scollToBottomButtonVisible = false
@ObservedObject var context: RoomScreenViewModel.Context
var body: some View {
// The observer behaves differently when not in an reader
ScrollViewReader { _ in
List {
HStack {
Spacer()
ProgressView()
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
.animation(.default, value: context.viewState.isBackPaginating)
Spacer()
}
// No idea why previews don't work otherwise
ForEach(isPreview ? context.viewState.items : timelineItems) { timelineItem in
timelineItem
.listRowSeparator(.hidden)
.onAppear {
context.send(viewAction: .itemAppeared(id: timelineItem.id))
}
.onDisappear {
context.send(viewAction: .itemDisappeared(id: timelineItem.id))
}
.environment(\.openURL, OpenURLAction { url in
context.send(viewAction: .linkClicked(url: url))
return .systemAction
})
}
}
.listStyle(.plain)
.environment(\.defaultMinListRowHeight, 0.0)
.introspectTableView { tableView in
if tableView == tableViewObserver.tableView {
return
}
tableViewObserver = TableViewObserver(tableView: tableView,
topDetectionOffset: (tableView.bounds.size.height / 3.0))
tableViewObserver.scrollToBottom()
// Check if there are enough items. Otherwise ask for more
attemptBackPagination()
}
.onAppear(perform: {
if timelineItems != context.viewState.items {
timelineItems = context.viewState.items
}
})
.onReceive(tableViewObserver.scrollViewDidReachTop, perform: {
if context.viewState.isBackPaginating {
return
}
attemptBackPagination()
})
.onChange(of: context.viewState.items) { _ in
// Don't update the list while moving
if tableViewObserver.isDecelerating || tableViewObserver.isTracking {
hasPendingChanges = true
return
}
tableViewObserver.saveCurrentOffset()
timelineItems = context.viewState.items
}
.onReceive(tableViewObserver.scrollViewDidRest, perform: {
if hasPendingChanges == false {
return
}
tableViewObserver.saveCurrentOffset()
timelineItems = context.viewState.items
hasPendingChanges = false
})
.onChange(of: timelineItems, perform: { _ in
tableViewObserver.restoreSavedOffset()
// Check if there are enough items. Otherwise ask for more
attemptBackPagination()
})
ZStack(alignment: .bottomTrailing) {
TimelineItemList(context: context, bottomVisiblePublisher: bottomVisiblePublisher, scrollToBottomPublisher: scrollToBottomPublisher)
scrollToBottomButton
}
}
private func attemptBackPagination() {
if context.viewState.isBackPaginating {
return
}
if tableViewObserver.isTopVisible == false {
return
}
context.send(viewAction: .loadPreviousPage)
}
private var isPreview: Bool {
#if DEBUG
return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
#else
return false
#endif
}
}
private class TableViewObserver: NSObject, UITableViewDelegate {
private enum ContentOffsetDetails {
case topOffset(previousVisibleIndexPath: IndexPath, previousItemCount: Int)
case bottomOffset
}
private let topDetectionOffset: CGFloat
private var contentOffsetObserverToken: NSKeyValueObservation?
private var isAtTop: Bool = false
private var offsetDetails: ContentOffsetDetails?
private var draggingInitiated = false
private(set) var tableView: UITableView?
let scrollViewDidRest = PassthroughSubject<Void, Never>()
let scrollViewDidReachTop = PassthroughSubject<Void, Never>()
override init() {
self.topDetectionOffset = 0.0
}
init(tableView: UITableView, topDetectionOffset: CGFloat) {
self.tableView = tableView
self.topDetectionOffset = topDetectionOffset
super.init()
// Don't attempt stealing the UITableView delegate away from the List.
// Doing so results in undefined behavior e.g. context menus not working
contentOffsetObserverToken = tableView.observe(\.contentOffset, options: .new, changeHandler: { [weak self] _, _ in
self?.handleScrollViewScroll()
@ViewBuilder
private var scrollToBottomButton: some View {
Button(action: {
scrollToBottomPublisher.send(())
}, label: {
Image(uiImage: Asset.Images.timelineScrollToBottom.image)
.shadow(radius: 2.0)
.padding()
})
tableView.panGestureRecognizer.addTarget(self, action: #selector(handlePanGesture(_:)))
}
func saveCurrentOffset() {
guard let tableView = tableView,
tableView.numberOfSections > 0 else {
return
}
if isBottomVisible {
offsetDetails = .bottomOffset
} else if isTopVisible {
if let topIndexPath = tableView.indexPathsForVisibleRows?.first {
offsetDetails = .topOffset(previousVisibleIndexPath: topIndexPath,
previousItemCount: tableView.numberOfRows(inSection: 0))
}
}
}
func restoreSavedOffset() {
defer {
offsetDetails = nil
}
guard let tableView = tableView,
tableView.numberOfSections > 0 else {
return
}
let currentItemCount = tableView.numberOfRows(inSection: 0)
switch offsetDetails {
case .bottomOffset:
tableView.scrollToRow(at: .init(row: max(0, currentItemCount - 1), section: 0), at: .bottom, animated: false)
case .topOffset(let indexPath, let previousItemCount):
let row = indexPath.row + max(0, (currentItemCount - previousItemCount))
if row < currentItemCount {
tableView.scrollToRow(at: .init(row: row, section: 0), at: .top, animated: false)
}
case .none:
break
}
}
var isTracking: Bool {
self.tableView?.isTracking == true
}
var isDecelerating: Bool {
self.tableView?.isDecelerating == true
}
var isTopVisible: Bool {
guard let scrollView = tableView else {
return false
}
return (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) <= topDetectionOffset
}
func scrollToBottom() {
guard let tableView = tableView,
tableView.numberOfSections > 0 else {
return
}
let currentItemCount = tableView.numberOfRows(inSection: 0)
guard currentItemCount > 1 else {
return
}
tableView.scrollToRow(at: .init(row: currentItemCount - 1, section: 0), at: .bottom, animated: false)
}
// MARK: - Private
private func handleScrollViewScroll() {
guard let tableView = self.tableView else {
return
}
let isTopVisible = self.isTopVisible
if self.isTopVisible && self.isAtTop != isTopVisible {
self.scrollViewDidReachTop.send(())
}
self.isAtTop = isTopVisible
if !self.draggingInitiated && tableView.isDragging {
self.draggingInitiated = true
} else if self.draggingInitiated && !tableView.isDragging {
self.draggingInitiated = false
self.scrollViewDidRest.send(())
}
}
@objc private func handlePanGesture(_ sender: UIPanGestureRecognizer) {
guard let tableView = self.tableView,
sender.state == .ended,
draggingInitiated == true,
!tableView.isDecelerating else {
return
}
self.draggingInitiated = false
self.scrollViewDidRest.send(())
}
private var isBottomVisible: Bool {
guard let scrollView = tableView else {
return false
}
return (scrollView.contentOffset.y) >= (scrollView.contentSize.height - scrollView.frame.size.height)
.onReceive(bottomVisiblePublisher, perform: { visible in
scollToBottomButtonVisible = !visible
})
.opacity(scollToBottomButtonVisible ? 1.0 : 0.0)
.animation(.default, value: scollToBottomButtonVisible)
}
}
struct TimelineView_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
@ViewBuilder
static var body: some View {
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
roomName: nil)
TimelineView(context: viewModel.context)
}
}
@@ -18,7 +18,7 @@
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Images/app-logo" translatesAutoresizingMaskIntoConstraints="NO" id="ue7-fB-5XS">
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Images/appLogo" translatesAutoresizingMaskIntoConstraints="NO" id="ue7-fB-5XS">
<rect key="frame" x="87" y="328" width="240" height="240"/>
<color key="tintColor" red="0.050980392156862744" green="0.74117647058823533" blue="0.54509803921568623" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</imageView>
@@ -37,7 +37,7 @@
</view>
</objects>
<resources>
<image name="Images/app-logo" width="240" height="240"/>
<image name="Images/appLogo" width="240" height="240"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
@@ -44,7 +44,7 @@ struct MediaProvider: MediaProviderProtocol {
processingQueue.async {
do {
let imageData = try client.loadImage(source: source.underlyingSource)
let imageData = try client.getMediaContent(source: source.underlyingSource)
guard let image = UIImage(data: Data(bytes: imageData, count: imageData.count)) else {
MXLog.error("Invalid image data")
@@ -46,4 +46,8 @@ struct MockRoomProxy: RoomProxyProtocol {
func displayNameForUserId(_ userId: String, completion: @escaping (Result<String?, RoomProxyError>) -> Void) {
}
func sendMessage(_ message: String, callback: ((Result<Void, RoomProxyError>) -> Void)?) {
}
}
@@ -165,6 +165,25 @@ class RoomProxy: RoomProxyProtocol {
}
}
func sendMessage(_ message: String, callback: ((Result<Void, RoomProxyError>) -> Void)?) {
let messageContent = messageEventContentFromMarkdown(md: message)
let transactionId = genTransactionId()
messageProcessingQueue.async {
do {
try self.room.send(msg: messageContent, txnId: transactionId)
DispatchQueue.main.async {
callback?(.success(()))
}
} catch {
DispatchQueue.main.async {
callback?(.failure(.failedSendingMessage))
}
}
}
}
// MARK: - Private
fileprivate func appendMessage(_ message: AnyMessage) {
@@ -14,6 +14,7 @@ enum RoomProxyError: Error {
case backwardStreamNotAvailable
case failedRetrievingMemberAvatarURL
case failedRetrievingMemberDisplayName
case failedSendingMessage
}
enum RoomProxyCallback {
@@ -43,5 +44,7 @@ protocol RoomProxyProtocol {
func paginateBackwards(count: UInt, callback: ((Result<Void, RoomProxyError>) -> Void)?)
func sendMessage(_ message: String, callback: ((Result<Void, RoomProxyError>) -> Void)?)
var callbacks: PassthroughSubject<RoomProxyCallback, Never> { get }
}
@@ -30,4 +30,8 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
func processItemDisappearance(_ itemId: String) {
}
func sendMessage(_ message: String) {
}
}
@@ -79,6 +79,10 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
}
func sendMessage(_ message: String) {
timelineProvider.sendMessage(message)
}
// MARK: - Private
@objc private func contentSizeCategoryDidChange() {
@@ -27,4 +27,6 @@ protocol RoomTimelineControllerProtocol {
func processItemAppearance(_ itemId: String)
func processItemDisappearance(_ itemId: String)
func sendMessage(_ message: String)
}
@@ -28,6 +28,10 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol {
}.store(in: &cancellables)
}
var messages: [RoomMessageProtocol] {
roomProxy.messages
}
func paginateBackwards(_ count: UInt, callback: ((Result<Void, RoomTimelineError>) -> Void)?) {
self.roomProxy.paginateBackwards(count: count) { result in
switch result {
@@ -39,7 +43,14 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol {
}
}
var messages: [RoomMessageProtocol] {
roomProxy.messages
func sendMessage(_ message: String) {
roomProxy.sendMessage(message) { result in
switch result {
case .success:
break
case .failure(let error):
MXLog.error("Failed sending message with error: \(error)")
}
}
}
}
@@ -23,4 +23,6 @@ protocol RoomTimelineProviderProtocol {
var messages: [RoomMessageProtocol] { get }
func paginateBackwards(_ count: UInt, callback: ((Result<Void, RoomTimelineError>) -> Void)?)
func sendMessage(_ message: String)
}