mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Implement message sending. Refactor the timeline. Fix various UI/UX issues and add scroll to bottom timeline button.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user