Compare commits

...

12 Commits

Author SHA1 Message Date
Levitating Pineapple 6b8c4ccb89 remove separator animation 2024-09-06 18:12:52 +03:00
Levitating Pineapple d3cc775a80 cleanup 2024-09-06 18:07:18 +03:00
Levitating Pineapple bab13fb355 revert view separation 2024-09-06 18:00:23 +03:00
Levitating Pineapple 459058de88 whitespace 2024-09-06 15:46:25 +03:00
Levitating Pineapple a0f2293174 merge master 2024-09-06 15:44:59 +03:00
Evgeny Poberezkin 781fd58369 Merge branch 'master' into lp/floating-buttons-date 2024-09-06 12:41:58 +01:00
Evgeny Poberezkin 6fe7ef4fc0 Merge branch 'master' into lp/floating-buttons-date 2024-09-03 14:40:21 +01:00
Levitating Pineapple 1bb8d5ea96 background date visibility 2024-08-30 22:23:53 +03:00
Levitating Pineapple 694a865b3f send tuple, reduce lookups 2024-08-30 16:12:20 +03:00
Levitating Pineapple e22660dfd9 revert formatTimestampText 2024-08-29 23:41:32 +03:00
Levitating Pineapple 0656836dd0 floating date separator 2024-08-29 23:11:57 +03:00
Levitating Pineapple 537e3c0b6c ios: add floating date separator 2024-08-29 21:43:18 +03:00
2 changed files with 163 additions and 75 deletions
+99 -18
View File
@@ -77,7 +77,12 @@ struct ChatView: View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
chatItemsList() chatItemsList()
// TODO: Extract into a separate view, to reduce the scope of `FloatingButtonModel` updates // TODO: Extract into a separate view, to reduce the scope of `FloatingButtonModel` updates
floatingButtons(unreadBelow: floatingButtonModel.unreadBelow, isNearBottom: floatingButtonModel.isNearBottom) floatingButtons(
unreadBelow: floatingButtonModel.unreadBelow,
isNearBottom: floatingButtonModel.isNearBottom,
date: floatingButtonModel.date,
isDateVisible: floatingButtonModel.isDateVisible
)
} }
connectingText() connectingText()
if selectedChatItems == nil { if selectedChatItems == nil {
@@ -453,27 +458,60 @@ struct ChatView: View {
static let shared = FloatingButtonModel() static let shared = FloatingButtonModel()
@Published var unreadBelow: Int = 0 @Published var unreadBelow: Int = 0
@Published var isNearBottom: Bool = true @Published var isNearBottom: Bool = true
@Published var date: Date?
@Published var isDateVisible: Bool = false
var isReallyNearBottom: Bool { scrollOffset.value > 0 && scrollOffset.value < 500 } var isReallyNearBottom: Bool { scrollOffset.value > 0 && scrollOffset.value < 500 }
let visibleItems = PassthroughSubject<[String], Never>() let listState = PassthroughSubject<ListState, Never>()
let scrollOffset = CurrentValueSubject<Double, Never>(0) let scrollOffset = CurrentValueSubject<Double, Never>(0)
private var bag = Set<AnyCancellable>() private var bag = Set<AnyCancellable>()
struct ListState: Equatable {
let topItemDate: Date?
let bottomItemId: ChatItem.ID?
}
private struct ViewUpdate: Equatable {
let unreadBelow: Int
let date: Date?
}
init() { init() {
visibleItems // Date
listState
.receive(on: DispatchQueue.global(qos: .background)) .receive(on: DispatchQueue.global(qos: .background))
.map { itemIds in .map { listState -> ViewUpdate in
if let viewId = itemIds.first, let im = ItemsModel.shared
let index = ItemsModel.shared.reversedChatItems.firstIndex(where: { $0.viewId == viewId }) { return ViewUpdate(
ItemsModel.shared.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in unreadBelow: {
if let id = listState.bottomItemId,
let index = im.reversedChatItems.firstIndex(where: { $0.id == id }) {
im.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in
if chatItem.isRcvNew { unread += 1 } if chatItem.isRcvNew { unread += 1 }
} }
} else { 0 } } else { 0 }
}(),
date: listState.topItemDate.map { Calendar.current.startOfDay(for: $0) }
)
} }
.removeDuplicates() .removeDuplicates()
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: \.unreadBelow, on: self) .sink {
self.unreadBelow = $0.unreadBelow
self.date = $0.date
}
.store(in: &bag) .store(in: &bag)
// Date visibility
listState
.removeDuplicates()
.map { _ in self.setDate(visibility: true) }
// Hide the date after 1 second of no scrolling
.debounce(for: 1, scheduler: DispatchQueue.main)
.sink { self.setDate(visibility: false) }
.store(in: &bag)
// Is near bottom
scrollOffset scrollOffset
.map { $0 < 800 } .map { $0 < 800 }
.removeDuplicates() .removeDuplicates()
@@ -482,9 +520,42 @@ struct ChatView: View {
.assign(to: \.isNearBottom, on: self) .assign(to: \.isNearBottom, on: self)
.store(in: &bag) .store(in: &bag)
} }
func resetDate() {
date = nil
isDateVisible = false
} }
private func floatingButtons(unreadBelow: Int, isNearBottom: Bool) -> some View { private func setDate(visibility isVisible: Bool) {
Task {
if isVisible {
if !isNearBottom,
!isDateVisible,
let date, !Calendar.current.isDateInToday(date) {
await MainActor.run {
isDateVisible = true
}
}
} else if isDateVisible {
await MainActor.run {
isDateVisible = false
}
}
}
}
}
@ViewBuilder
private func floatingButtons(unreadBelow: Int, isNearBottom: Bool, date: Date?, isDateVisible: Bool) -> some View {
ZStack(alignment: .top) {
if let date {
DateSeparator(date: date)
.padding(.vertical, 4).padding(.horizontal, 8)
.background(.thinMaterial)
.clipShape(Capsule())
.opacity(isDateVisible ? 1 : 0)
}
VStack { VStack {
let unreadAbove = chat.chatStats.unreadCount - unreadBelow let unreadAbove = chat.chatStats.unreadCount - unreadBelow
if unreadAbove > 0 { if unreadAbove > 0 {
@@ -525,6 +596,9 @@ struct ChatView: View {
} }
} }
.padding() .padding()
.frame(maxWidth: .infinity, alignment: .trailing)
}
.onDisappear(perform: floatingButtonModel.resetDate)
} }
private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View { private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View {
@@ -536,6 +610,21 @@ struct ChatView: View {
} }
} }
private struct DateSeparator: View {
let date: Date
var body: some View {
Text(String.localizedStringWithFormat(
NSLocalizedString("%@, %@", comment: "format for date separator in chat"),
date.formatted(.dateTime.weekday(.abbreviated)),
date.formatted(.dateTime.day().month(.abbreviated))
))
.font(.callout)
.fontWeight(.medium)
.foregroundStyle(.secondary)
}
}
private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View { private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View {
Button { Button {
CallController.shared.startCall(contact, media) CallController.shared.startCall(contact, media)
@@ -743,15 +832,7 @@ struct ChatView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
chatItemView(chatItem, range, prevItem, timeSeparation) chatItemView(chatItem, range, prevItem, timeSeparation)
if let date = timeSeparation.date { if let date = timeSeparation.date {
Text(String.localizedStringWithFormat( DateSeparator(date: date).padding(8)
NSLocalizedString("%@, %@", comment: "format for date separator in chat"),
date.formatted(.dateTime.weekday(.abbreviated)),
date.formatted(.dateTime.day().month(.abbreviated))
))
.font(.callout)
.fontWeight(.medium)
.foregroundStyle(.secondary)
.padding(8)
} }
} }
.overlay { .overlay {
+19 -12
View File
@@ -204,24 +204,31 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
} }
private func updateVisibleItems() { private func updateVisibleItems() {
if let visibleRows = tableView.indexPathsForVisibleRows,
visibleRows.last?.item ?? 0 < representer.items.count {
let fbm = ChatView.FloatingButtonModel.shared let fbm = ChatView.FloatingButtonModel.shared
fbm.scrollOffset.send(tableView.contentOffset.y + InvertedTableView.inset) fbm.scrollOffset.send(tableView.contentOffset.y + InvertedTableView.inset)
fbm.visibleItems.send( fbm.listState.send(.init(
(tableView.indexPathsForVisibleRows ?? []) topItemDate: visibleRows
.compactMap { indexPath -> String? in .last { isVisible(indexPath: $0) }
guard let relativeFrame = tableView.superview?.convert( .map { representer.items[$0.item] }?
.meta.itemTs,
bottomItemId: visibleRows
.first { isVisible(indexPath: $0) }
.map { representer.items[$0.item] }?
.id
))
}
}
private func isVisible(indexPath: IndexPath) -> Bool {
if let relativeFrame = tableView.superview?.convert(
tableView.rectForRow(at: indexPath), tableView.rectForRow(at: indexPath),
from: tableView from: tableView
) else { return nil } ) {
// Checks that the cell is visible accounting for the added insets
let isVisible =
relativeFrame.maxY > InvertedTableView.inset && relativeFrame.maxY > InvertedTableView.inset &&
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
return indexPath.item < representer.items.count && isVisible } else { false }
? representer.items[indexPath.item].viewId
: nil
}
)
} }
} }