Introduced the RoomTimelineViewProvider with different timeline items/views. Added timeline date separators (currently breaks back pagination)

This commit is contained in:
Stefan Ceriu
2022-03-11 14:47:11 +02:00
parent c0de77f800
commit 464cba93a0
17 changed files with 329 additions and 192 deletions

View File

@@ -117,12 +117,16 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy)
let coordinator = RoomScreenCoordinator(parameters: parameters)
coordinator.completion = { [weak self] result in
coordinator.completion = { _ in
}
self.add(childCoordinator: coordinator)
self.navigationRouter.push(coordinator)
self.navigationRouter.push(coordinator) { [weak self] in
guard let self = self else { return }
self.remove(childCoordinator: coordinator)
}
}
}

View File

@@ -24,79 +24,76 @@ struct HomeScreen: View {
// MARK: Views
var body: some View {
NavigationView {
VStack(spacing: 16.0) {
if context.viewState.isLoadingRooms {
VStack {
Text("Loading rooms")
ProgressView()
}
} else {
List {
Section("Rooms") {
ForEach(context.viewState.unencryptedRooms) { room in
RoomCell(room: room, context: context)
}
let other = context.viewState.encryptedRooms
if other.count > 0 {
DisclosureGroup("Encrypted") {
ForEach(other) { room in
RoomCell(room: room, context: context)
}
}
}
VStack(spacing: 16.0) {
if context.viewState.isLoadingRooms {
VStack {
Text("Loading rooms")
ProgressView()
}
} else {
List {
Section("Rooms") {
ForEach(context.viewState.unencryptedRooms) { room in
RoomCell(room: room, context: context)
}
Section("People") {
ForEach(context.viewState.unencryptedDMs) { room in
RoomCell(room: room, context: context)
}
let other = context.viewState.encryptedDMs
if other.count > 0 {
DisclosureGroup("Encrypted") {
ForEach(other) { room in
RoomCell(room: room, context: context)
}
let other = context.viewState.encryptedRooms
if other.count > 0 {
DisclosureGroup("Encrypted") {
ForEach(other) { room in
RoomCell(room: room, context: context)
}
}
}
}
.listStyle(.plain)
}
Spacer()
}
.ignoresSafeArea(.all, edges: .bottom)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
HStack {
if let avatar = context.viewState.userAvatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.frame(width: 40, height: 40, alignment: .center)
.mask(Circle())
} else {
let _ = context.send(viewAction: .loadUserAvatar)
Section("People") {
ForEach(context.viewState.unencryptedDMs) { room in
RoomCell(room: room, context: context)
}
let other = context.viewState.encryptedDMs
if other.count > 0 {
DisclosureGroup("Encrypted") {
ForEach(other) { room in
RoomCell(room: room, context: context)
}
}
}
Text("Hello, \(context.viewState.userDisplayName)!")
.font(.subheadline)
.fontWeight(.bold)
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Logout") {
context.send(viewAction: .logout)
.listStyle(.plain)
}
Spacer()
}
.ignoresSafeArea(.all, edges: .bottom)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
HStack {
if let avatar = context.viewState.userAvatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.frame(width: 40, height: 40, alignment: .center)
.mask(Circle())
} else {
let _ = context.send(viewAction: .loadUserAvatar)
}
Text("Hello, \(context.viewState.userDisplayName)!")
.font(.subheadline)
.fontWeight(.bold)
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Logout") {
context.send(viewAction: .logout)
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}

View File

@@ -22,9 +22,11 @@ enum RoomScreenViewModelResult {
enum RoomScreenViewAction {
case loadPreviousPage
case itemAppeared(id: String)
case itemDisappeared(id: String)
}
struct RoomScreenViewState: BindableState {
var roomTitle: String = ""
var messages: [RoomTimelineItem] = []
var timelineItems: [RoomTimelineViewProvider] = []
}

View File

@@ -41,24 +41,28 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
super.init(initialViewState: RoomScreenViewState())
state.roomTitle = roomProxy.name ?? ""
state.messages = timelineController.timelineItems
state.timelineItems = timelineController.timelineItems
timelineController.callbacks.sink { [weak self] callback in
guard let self = self else { return }
switch callback {
case .updatedTimelineItems:
self.state.messages = timelineController.timelineItems
self.state.timelineItems = timelineController.timelineItems
}
}.store(in: &cancellables)
}
// MARK: - Public
override func process(viewAction: RoomScreenViewAction) {
switch viewAction {
case .loadPreviousPage:
timelineController.paginateBackwards(Constants.backPaginationPageSize)
case .itemAppeared:
break
case .itemDisappeared:
break
}
}
}

View File

@@ -21,7 +21,7 @@ import Combine
struct RoomScreen: View {
@State private var scrollViewObserver: ScrollViewObserver = ScrollViewObserver()
@State private var messages: [RoomTimelineItem] = []
@State private var timelineItems: [RoomTimelineViewProvider] = []
@State private var didRequestBackPagination = false
@State private var hasPendingMessages = false
@@ -58,8 +58,15 @@ struct RoomScreen: View {
}
}
ForEach(messages) { message in
message.body
ForEach(timelineItems) { timelineItem in
timelineItem
.listRowSeparator(.hidden)
.onAppear {
context.send(viewAction: .itemAppeared(id: timelineItem.id))
}
.onDisappear {
context.send(viewAction: .itemDisappeared(id: timelineItem.id))
}
}
Color.clear
@@ -85,8 +92,8 @@ struct RoomScreen: View {
// When the view state changes check whether the user is interacting with the scroll view.
// Updating in that case causes undesired scrolling. Delay until the scroll view stops scrolling.
// Also store previous top most message identifier to have something to scroll to after the update.
.onChange(of: context.viewState.messages) { newValue in
previousTopMostMessageIdentifier = messages.first?.id
.onChange(of: context.viewState.timelineItems) { newValue in
previousTopMostMessageIdentifier = timelineItems.first?.id
wasBottomVisible = scrollViewObserver.isBottomVisible
if scrollViewObserver.isTracking == true {
@@ -94,17 +101,17 @@ struct RoomScreen: View {
return
}
messages = newValue
timelineItems = newValue
}
// Check if we have pending messages to apply and apply them when the scroll finishes scrolling
.onReceive(scrollViewObserver.didEndScrolling, perform: {
if hasPendingMessages {
messages = context.viewState.messages
timelineItems = context.viewState.timelineItems
hasPendingMessages = false
}
})
// Process timeline updates
.onChange(of: messages, perform: { _ in
.onChange(of: timelineItems, perform: { _ in
if didRequestBackPagination && wasBottomVisible {
scrollViewProxy.scrollTo(timelineBottomAnchor, anchor: .bottom)
} else if didRequestBackPagination == false {

View File

@@ -10,12 +10,16 @@ import Foundation
import Combine
class MockRoomTimelineController: RoomTimelineControllerProtocol {
let timelineItems: [RoomTimelineItem] = [RoomTimelineItem.text(id: UUID().uuidString, senderDisplayName: "Anne", text: "You rock!", originServerTs: .now, shouldShowSenderDetails: true),
RoomTimelineItem.text(id: UUID().uuidString, senderDisplayName: "Anne", text: "Some other message from Anne", originServerTs: .now, shouldShowSenderDetails: false),
RoomTimelineItem.sectionTitle(id: UUID().uuidString, text: "The next day"),
RoomTimelineItem.text(id: UUID().uuidString, senderDisplayName: "Bob", text: "You rule!", originServerTs: .now, shouldShowSenderDetails: true)]
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
let timelineItems: [RoomTimelineViewProvider] =
[RoomTimelineViewProvider.separator(.init(id: UUID().uuidString, text: "Yesterday")),
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Alice", text: "You rock!", timestamp: "10:10 AM", shouldShowSenderDetails: true)),
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Alice", text: "You also rule!", timestamp: "10:11 AM", shouldShowSenderDetails: false)),
RoomTimelineViewProvider.separator(.init(id: UUID().uuidString, text: "Today")),
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Bob", text: "You too!", timestamp: "5 PM", shouldShowSenderDetails: true))]
func paginateBackwards(_ count: UInt) {
}

View File

@@ -14,20 +14,13 @@ enum RoomTimelineControllerCallback {
case updatedTimelineItems
}
private var sectionTitleDateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .long
dateFormatter.timeStyle = .none
return dateFormatter
}()
class RoomTimelineController: RoomTimelineControllerProtocol {
private let timelineProvider: RoomTimelineProvider
private var cancellables = Set<AnyCancellable>()
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
private(set) var timelineItems = [RoomTimelineItem]()
private(set) var timelineItems = [RoomTimelineViewProvider]()
init(timelineProvider: RoomTimelineProvider) {
self.timelineProvider = timelineProvider
@@ -37,32 +30,34 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
switch callback {
case .updatedMessages:
var newTimelineItems = [RoomTimelineItem]()
var newTimelineItems = [RoomTimelineViewProvider]()
var previousMessage: Message?
var previousSender: String?
for message in self.timelineProvider.messages {
let timestamp = Date(timeIntervalSince1970: TimeInterval(message.originServerTs()))
let areMessagesFromTheSameDay = self.haveSameDay(lhs: previousMessage, rhs: message)
// let shouldAddSectionHeader = !areMessagesFromTheSameDay
//
// if shouldAddSectionHeader {
// newTimelineItems.append(RoomTimelineItem.sectionTitle(id: message.id(),
// text: sectionTitleDateFormatter.string(from: timestamp)))
// }
let shouldAddSectionHeader = !areMessagesFromTheSameDay
let areMessagesFromTheSameSender = previousSender == message.sender()
if shouldAddSectionHeader {
let item = SeparatorRoomTimelineItem(id: timestamp.ISO8601Format(),
text: timestamp.formatted(date: .long, time: .omitted))
newTimelineItems.append(RoomTimelineViewProvider.separator(item))
}
let areMessagesFromTheSameSender = (previousMessage?.sender() == message.sender())
let shouldShowSenderDetails = !areMessagesFromTheSameSender || !areMessagesFromTheSameDay
newTimelineItems.append(RoomTimelineItem.text(id: message.id(),
senderDisplayName: message.sender(),
text: message.content(),
originServerTs: timestamp,
shouldShowSenderDetails: shouldShowSenderDetails))
let item = TextRoomTimelineItem(id: message.id(),
senderDisplayName: message.sender(),
text: message.content(),
timestamp: timestamp.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: shouldShowSenderDetails)
newTimelineItems.append(RoomTimelineViewProvider.text(item))
previousMessage = message
previousSender = message.sender()
}
self.timelineItems = newTimelineItems

View File

@@ -10,7 +10,7 @@ import Foundation
import Combine
protocol RoomTimelineControllerProtocol {
var timelineItems: [RoomTimelineItem] { get }
var timelineItems: [RoomTimelineViewProvider] { get }
var callbacks: PassthroughSubject<RoomTimelineControllerCallback, Never> { get }
func paginateBackwards(_ count: UInt)

View File

@@ -0,0 +1,14 @@
//
// ImageRoomTimelineItem.swift
// ElementX
//
// Created by Stefan Ceriu on 11/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
struct ImageRoomTimelineItem: Identifiable, Equatable {
let id: String
let text: String
}

View File

@@ -0,0 +1,23 @@
//
// ImageRoomTimelineView.swift
// ElementX
//
// Created by Stefan Ceriu on 11/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import SwiftUI
struct ImageRoomTimelineView: View {
let timelineItem: ImageRoomTimelineItem
var loadedImage: UIImage?
var body: some View {
if let loadedImage = loadedImage {
Image(uiImage: loadedImage)
} else {
ProgressView()
}
}
}

View File

@@ -1,84 +0,0 @@
//
// TextRoomTimelineItem.swift
// ElementX
//
// Created by Stefan Ceriu on 04.03.2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import SwiftUI
private var dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .none
dateFormatter.timeStyle = .short
return dateFormatter
}()
enum RoomTimelineItem: Identifiable, Equatable {
case text(id: String, senderDisplayName: String, text: String, originServerTs: Date, shouldShowSenderDetails: Bool)
case sectionTitle(id: String, text: String)
var id: String {
switch self {
case .text(let id, _, _, _, _):
return id
case .sectionTitle(let id, _):
return id
}
}
}
extension RoomTimelineItem: View {
var body: some View {
switch self {
case .text(let id, let senderDisplayName, let text, let originServerTs, let shouldShowSenderDetails):
VStack(alignment: .leading) {
if shouldShowSenderDetails {
HStack {
Text(senderDisplayName)
.font(.footnote)
.bold()
Spacer()
Text(dateFormatter.string(from: originServerTs))
.font(.footnote)
}
Divider()
Spacer()
}
Text(text)
}
.listRowSeparator(.hidden)
.id(id)
case .sectionTitle(let id, let text):
LabelledDivider(label: text)
.id(id)
}
}
}
struct LabelledDivider: View {
let label: String
let color: Color
init(label: String, color: Color = .gray) {
self.label = label
self.color = color
}
var body: some View {
HStack {
line
Text(label)
.foregroundColor(color)
.fixedSize()
line
}
}
var line: some View {
VStack { Divider().background(color) }
}
}

View File

@@ -0,0 +1,40 @@
//
// TextRoomTimelineItem.swift
// ElementX
//
// Created by Stefan Ceriu on 04.03.2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import SwiftUI
enum RoomTimelineViewProvider: Identifiable, Equatable {
case text(TextRoomTimelineItem)
case separator(SeparatorRoomTimelineItem)
case image(ImageRoomTimelineItem)
var id: String {
switch self {
case .text(let item):
return item.id
case .separator(let item):
return item.id
case .image(let item):
return item.id
}
}
}
extension RoomTimelineViewProvider: View {
@ViewBuilder var body: some View {
switch self {
case .text(let item):
TextRoomTimelineView(timelineItem: item)
case .separator(let item):
SeparatorRoomTimelineView(timelineItem: item)
case .image(let item):
ImageRoomTimelineView(timelineItem: item)
}
}
}

View File

@@ -0,0 +1,14 @@
//
// SectionSeparatorTimelineItem.swift
// ElementX
//
// Created by Stefan Ceriu on 11/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
struct SeparatorRoomTimelineItem: Identifiable, Equatable {
let id: String
let text: String
}

View File

@@ -0,0 +1,43 @@
//
// SeparatorRoomTimelineView.swift
// ElementX
//
// Created by Stefan Ceriu on 11/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import SwiftUI
struct SeparatorRoomTimelineView: View {
let timelineItem: SeparatorRoomTimelineItem
var body: some View {
LabelledDivider(label: timelineItem.text)
.id(timelineItem.id)
}
}
struct LabelledDivider: View {
let label: String
let color: Color
init(label: String, color: Color = .gray) {
self.label = label
self.color = color
}
var body: some View {
HStack {
line
Text(label)
.foregroundColor(color)
.fixedSize()
line
}
}
var line: some View {
VStack { Divider().background(color) }
}
}

View File

@@ -0,0 +1,17 @@
//
// TextRoomTimelineItem.swift
// ElementX
//
// Created by Stefan Ceriu on 11/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
struct TextRoomTimelineItem: Identifiable, Equatable {
let id: String
let senderDisplayName: String
let text: String
let timestamp: String
let shouldShowSenderDetails: Bool
}

View File

@@ -0,0 +1,33 @@
//
// TextRoomTimelineView.swift
// ElementX
//
// Created by Stefan Ceriu on 11/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import SwiftUI
struct TextRoomTimelineView: View {
let timelineItem: TextRoomTimelineItem
var body: some View {
VStack(alignment: .leading) {
if timelineItem.shouldShowSenderDetails {
HStack {
Text(timelineItem.senderDisplayName)
.font(.footnote)
.bold()
Spacer()
Text(timelineItem.timestamp)
.font(.footnote)
}
Divider()
Spacer()
}
Text(timelineItem.text)
}
.id(timelineItem.id)
}
}