Implement message sending. Refactor the timeline. Fix various UI/UX issues and add scroll to bottom timeline button.
@ -11,6 +11,7 @@
|
||||
02D8DF8EB7537EB4E9019DDB /* EventBasedTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 218AB05B4E3889731959C5F1 /* EventBasedTimelineItemProtocol.swift */; };
|
||||
03B8FEA668A5B76A93113BB1 /* MemberDetailProviderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2ABC1A9B62BDB3D216E7FD /* MemberDetailProviderManager.swift */; };
|
||||
059173B3C77056C406906B6D /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = D4DA544B2520BFA65D6DB4BB /* target.yml */; };
|
||||
072BA9DBA932374CCA300125 /* MessageComposerTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6C10032A77AE7DC5AA4C50 /* MessageComposerTextField.swift */; };
|
||||
0EE5EBA18BA1FE10254BB489 /* UIFont+AttributedStringBuilder.m in Sources */ = {isa = PBXBuildFile; fileRef = E8CA187FE656EE5A3F6C7DE5 /* UIFont+AttributedStringBuilder.m */; };
|
||||
1281625B25371BE53D36CB3A /* SeparatorRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1ED7E89865201EE7D53E6DA /* SeparatorRoomTimelineItem.swift */; };
|
||||
12F70C493FB69F4D7E9A37EA /* NavigationRouterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29EBCBFEC6FD0941749404D /* NavigationRouterStore.swift */; };
|
||||
@ -22,6 +23,7 @@
|
||||
20563476E4766B9C3035E461 /* ElementXUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A069578069541F94F2AF016C /* ElementXUITests.swift */; };
|
||||
224A55EEAEECF5336B14A4A5 /* EmoteRoomMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2DF459F1737A594667CC46 /* EmoteRoomMessage.swift */; };
|
||||
22DADD537401E79D66132134 /* NavigationRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4173A48FD8542CD4AD3645C /* NavigationRouter.swift */; };
|
||||
24906A1E82D0046655958536 /* MessageComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CF12478983A5EB390FB26 /* MessageComposer.swift */; };
|
||||
277D2531C70F207A2F9F5906 /* KeychainControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956BDA4AE16429AD015661A8 /* KeychainControllerProtocol.swift */; };
|
||||
29AEE68A604940180AB9EBFF /* MockRoomSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BDAC8895AB2B77B47703AE /* MockRoomSummary.swift */; };
|
||||
2C0CE61E5DC177938618E0B1 /* RootRouterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90733775209F4D4D366A268F /* RootRouterType.swift */; };
|
||||
@ -46,6 +48,7 @@
|
||||
418B4AEFD03DC7A6D2C9D5C8 /* EventBriefFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36322DD0D4E29D31B0945ADC /* EventBriefFactory.swift */; };
|
||||
46562110EE202E580A5FFD9C /* RoomScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */; };
|
||||
49F2E7DD8CAACE09CEECE3E6 /* SeparatorRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6390A6DC140CA3D6865A66FF /* SeparatorRoomTimelineView.swift */; };
|
||||
4D970CB606276717B43E2332 /* TimelineItemList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804F9B0FABE093C7284CD09B /* TimelineItemList.swift */; };
|
||||
4E50727077B53D26A7C3E504 /* ElementXUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCEAA8205BCCCB8DBE01724 /* ElementXUITestsLaunchTests.swift */; };
|
||||
4E945AD6862C403F74E57755 /* RoomTimelineItemFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */; };
|
||||
4ED453A61AF45EBE18D8BC69 /* NavigationModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F77E8010D41AA3F5F9A1FCA /* NavigationModule.swift */; };
|
||||
@ -117,6 +120,7 @@
|
||||
D0619D2E6B9C511190FBEB95 /* RoomMessageProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607974D08BD2AF83725D817A /* RoomMessageProtocol.swift */; };
|
||||
D5EA4C6C80579279770D5804 /* ImageRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A45283CF1DB96E583BECA6 /* ImageRoomTimelineView.swift */; };
|
||||
D735AF72894A273F53D941B8 /* Activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B33C10BC91ABFF09605DB6 /* Activity.swift */; };
|
||||
D826154612415D2A3BB6EBF3 /* ListTableViewAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E854E7CF531DAC5CBEBDC75 /* ListTableViewAdapter.swift */; };
|
||||
DCB781BD227CA958809AFADF /* Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95CC95CD75B688E946438165 /* Coordinator.swift */; };
|
||||
DD4ADDB73E0935B74D2D18D6 /* UserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E3FE65EE63CBA65592863C2 /* UserSession.swift */; };
|
||||
DDB80FD2753FEAAE43CC2AAE /* ImageRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A63815AD6A5C306453342F2 /* ImageRoomTimelineItem.swift */; };
|
||||
@ -192,6 +196,7 @@
|
||||
49EAD710A2C16EFF7C3EA16F /* Benchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Benchmark.swift; sourceTree = "<group>"; };
|
||||
4CD6AC7546E8D7E5C73CEA48 /* ElementX.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = ElementX.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
4D6E4C37E9F0E53D3DF951AC /* HomeScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenUITests.swift; sourceTree = "<group>"; };
|
||||
4E854E7CF531DAC5CBEBDC75 /* ListTableViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTableViewAdapter.swift; sourceTree = "<group>"; };
|
||||
4F49CDE349C490D617332770 /* NoticeRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomTimelineItem.swift; sourceTree = "<group>"; };
|
||||
505208F28007C0FEC14E1FF0 /* HomeScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenViewModelTests.swift; sourceTree = "<group>"; };
|
||||
5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreen.swift; sourceTree = "<group>"; };
|
||||
@ -221,6 +226,7 @@
|
||||
7D0CBC76C80E04345E11F2DB /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
|
||||
7DDBF99755A9008CF8C8499E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
7FFCC48E7F701B6C24484593 /* WeakDictionaryKeyReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakDictionaryKeyReference.swift; sourceTree = "<group>"; };
|
||||
804F9B0FABE093C7284CD09B /* TimelineItemList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemList.swift; sourceTree = "<group>"; };
|
||||
81B17DB1BC3B0C62AF84D230 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
8210612D17A39369480FC183 /* MediaSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSource.swift; sourceTree = "<group>"; };
|
||||
874A1842477895F199567BD7 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = "<group>"; };
|
||||
@ -253,6 +259,7 @@
|
||||
B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
B902EA6CD3296B0E10EE432B /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; };
|
||||
BA97D630B74B0616C1468CBD /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreen.swift; sourceTree = "<group>"; };
|
||||
BE6C10032A77AE7DC5AA4C50 /* MessageComposerTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposerTextField.swift; sourceTree = "<group>"; };
|
||||
C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXAttributeScope.swift; sourceTree = "<group>"; };
|
||||
C21ECC295F4DE8DAA86D62AC /* RoomSummaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProtocol.swift; sourceTree = "<group>"; };
|
||||
C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenModels.swift; sourceTree = "<group>"; };
|
||||
@ -270,6 +277,7 @@
|
||||
DBFEAC3AC691CBB84983E275 /* ElementXTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXTests.swift; sourceTree = "<group>"; };
|
||||
DC54146B646F161762B54BBF /* ActivityPresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityPresentable.swift; sourceTree = "<group>"; };
|
||||
E09C9DFFE9A897E439D770C5 /* ToastActivityPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastActivityPresenter.swift; sourceTree = "<group>"; };
|
||||
E18CF12478983A5EB390FB26 /* MessageComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposer.swift; sourceTree = "<group>"; };
|
||||
E5272BC4A60B6AD7553BACA1 /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; };
|
||||
E8CA187FE656EE5A3F6C7DE5 /* UIFont+AttributedStringBuilder.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIFont+AttributedStringBuilder.m"; sourceTree = "<group>"; };
|
||||
E8FD25EB4DF66625B74E4505 /* LoginScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
@ -529,7 +537,11 @@
|
||||
79023E5904B155E8E2B8B502 /* View */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E854E7CF531DAC5CBEBDC75 /* ListTableViewAdapter.swift */,
|
||||
E18CF12478983A5EB390FB26 /* MessageComposer.swift */,
|
||||
BE6C10032A77AE7DC5AA4C50 /* MessageComposerTextField.swift */,
|
||||
5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */,
|
||||
804F9B0FABE093C7284CD09B /* TimelineItemList.swift */,
|
||||
874A1842477895F199567BD7 /* TimelineView.swift */,
|
||||
B7D3886505ECC85A06DA8258 /* Timeline */,
|
||||
);
|
||||
@ -990,6 +1002,7 @@
|
||||
2D8A687149E46B8C8B989561 /* KeychainController.swift in Sources */,
|
||||
277D2531C70F207A2F9F5906 /* KeychainControllerProtocol.swift in Sources */,
|
||||
539F448EC0250880703775DE /* LabelledActivityIndicatorView.swift in Sources */,
|
||||
D826154612415D2A3BB6EBF3 /* ListTableViewAdapter.swift in Sources */,
|
||||
A941EAD7F407F2ED6DA54A31 /* LoginScreen.swift in Sources */,
|
||||
306CC09DF101E7E9CDE79AA5 /* LoginScreenCoordinator.swift in Sources */,
|
||||
E9CEAF2C38E4E00459B811D9 /* LoginScreenModels.swift in Sources */,
|
||||
@ -1003,6 +1016,8 @@
|
||||
03B8FEA668A5B76A93113BB1 /* MemberDetailProviderManager.swift in Sources */,
|
||||
1999ECC6777752A2616775CF /* MemberDetailsProvider.swift in Sources */,
|
||||
A5EC21A071F58FC1229C20D0 /* MemberDetailsProviderProtocol.swift in Sources */,
|
||||
24906A1E82D0046655958536 /* MessageComposer.swift in Sources */,
|
||||
072BA9DBA932374CCA300125 /* MessageComposerTextField.swift in Sources */,
|
||||
67E391A2E00709FB41903B36 /* MockMediaProvider.swift in Sources */,
|
||||
51DB67C5B5BC68B0A6FF54D4 /* MockRoomProxy.swift in Sources */,
|
||||
29AEE68A604940180AB9EBFF /* MockRoomSummary.swift in Sources */,
|
||||
@ -1047,6 +1062,7 @@
|
||||
D013E70C8E28E43497820444 /* TextRoomMessage.swift in Sources */,
|
||||
7963F98CDFDEAC75E072BD81 /* TextRoomTimelineItem.swift in Sources */,
|
||||
5E0F2E612718BB4397A6D40A /* TextRoomTimelineView.swift in Sources */,
|
||||
4D970CB606276717B43E2332 /* TimelineItemList.swift in Sources */,
|
||||
500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */,
|
||||
15FEC447B7FEA62F418732AC /* ToastActivityPresenter.swift in Sources */,
|
||||
0EE5EBA18BA1FE10254BB489 /* UIFont+AttributedStringBuilder.m in Sources */,
|
||||
@ -1209,6 +1225,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@ -1270,6 +1287,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
|
@ -42,7 +42,7 @@
|
||||
"location" : "https://github.com/matrix-org/matrix-rust-components-swift.git",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "f6682cf02eec087f921c53526895996cba169378"
|
||||
"revision" : "bb19ced9e8889eb73e3989346d51992b82228a58"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -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")
|
||||
|
152
ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift
Normal file
@ -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)
|
||||
}
|
||||
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "send_message_icon.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "send_message_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "send_message_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 960 B |
After Width: | Height: | Size: 1.7 KiB |
After Width: | Height: | Size: 2.5 KiB |
23
ElementX/SupportingFiles/Assets.xcassets/Images/timelineScrollToBottom.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "timelineScrollToBottom.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "timelineScrollToBottom@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "timelineScrollToBottom@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 735 B |
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 1.9 KiB |
@ -8,6 +8,8 @@ fileGroups:
|
||||
options:
|
||||
groupSortPosition: bottom
|
||||
createIntermediateGroups: true
|
||||
deploymentTarget:
|
||||
iOS: "15.0"
|
||||
|
||||
include:
|
||||
- path: ElementX/SupportingFiles/target.yml
|
||||
|