From 5c4ca7442683a9a305fdc8c1fb50fd5793854e44 Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Tue, 22 Nov 2022 13:28:35 +0000 Subject: [PATCH] Use a VStack on the timeline (#332) * Use a VStack for the timeline. Replace edge publishers with a binding. * Allow both top and bottom edges to be detected. * Fix scrolling with frame changes. --- ElementX.xcodeproj/project.pbxproj | 16 +- .../Sources/Other/ReversedScrollView.swift | 82 ---------- .../View/Timeline/ImageRoomTimelineView.swift | 3 +- .../View/Timeline/VideoRoomTimelineView.swift | 3 +- .../RoomScreen/View/TimelineItemList.swift | 150 ++++++++---------- .../RoomScreen/View/TimelineScrollView.swift | 76 +++++++++ .../RoomScreen/View/TimelineView.swift | 22 ++- changelog.d/pr-332.change | 1 + 8 files changed, 160 insertions(+), 193 deletions(-) delete mode 100644 ElementX/Sources/Other/ReversedScrollView.swift create mode 100644 ElementX/Sources/Screens/RoomScreen/View/TimelineScrollView.swift create mode 100644 changelog.d/pr-332.change diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index e3a694ba0..5a3169b3e 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -339,7 +339,7 @@ BB6B0B91CE11E06330017000 /* SessionVerificationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC1A01C3A745BDF1D3697D3 /* SessionVerificationScreen.swift */; }; BCEC41FB1F2BB663183863E4 /* LoginServerInfoSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D379E13DD9D987470A3C70C /* LoginServerInfoSection.swift */; }; BFB534E338A3D949944FB2F5 /* NotificationServiceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B490675B8E31423AF116BDA /* NotificationServiceProxy.swift */; }; - BFD1AC03B6F8C5F5897D5B55 /* ReversedScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2DE30233B57761F8AFEB415 /* ReversedScrollView.swift */; }; + BFD1AC03B6F8C5F5897D5B55 /* TimelineScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2DE30233B57761F8AFEB415 /* TimelineScrollView.swift */; }; C3522917C0C367C403429EEC /* CoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B251F5B4511D1CA0BA8361FE /* CoordinatorProtocol.swift */; }; C35CF4DAB1467FE1BBDC204B /* MessageTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEAF1C75771D9DC75877F4B4 /* MessageTimelineItem.swift */; }; C4180F418235DAD9DD173951 /* TemplateScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9873076F224E4CE09D8BD47D /* TemplateScreenUITests.swift */; }; @@ -730,12 +730,12 @@ 892E29C98C4E8182C9037F84 /* TimelineStyler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyler.swift; sourceTree = ""; }; 8A9AE4967817E9608E22EB44 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 8AC1A01C3A745BDF1D3697D3 /* SessionVerificationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreen.swift; sourceTree = ""; }; - 8B9A55AC2FB0FE0AEAA3DF1F /* LICENSE */ = {isa = PBXFileReference; path = LICENSE; sourceTree = ""; }; + 8B9A55AC2FB0FE0AEAA3DF1F /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; 8C0AA893D6F8A2F563E01BB9 /* in */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = in; path = in.lproj/Localizable.stringsdict; sourceTree = ""; }; 8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineProvider.swift; sourceTree = ""; }; 8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = ""; }; - 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = ""; }; + 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITests.xctestplan; sourceTree = ""; }; 8ED2D2F6A137A95EA50413BE /* UserNotificationControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationControllerProtocol.swift; sourceTree = ""; }; 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummaryProvider.swift; sourceTree = ""; }; 8FC803282F9268D49F4ABF14 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; @@ -844,7 +844,7 @@ C070FD43DC6BF4E50217965A /* LocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationTests.swift; sourceTree = ""; }; C1198B925F4A88DA74083662 /* OnboardingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewModel.swift; sourceTree = ""; }; C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenModels.swift; sourceTree = ""; }; - C2DE30233B57761F8AFEB415 /* ReversedScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReversedScrollView.swift; sourceTree = ""; }; + C2DE30233B57761F8AFEB415 /* TimelineScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineScrollView.swift; sourceTree = ""; }; C3F652E88106B855A2A55ADE /* FilePreviewViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewViewModelProtocol.swift; sourceTree = ""; }; C444092DB0E4AB393067AC36 /* MediaPlayerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerViewModelTests.swift; sourceTree = ""; }; C483956FA3D665E3842E319A /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; }; @@ -928,7 +928,7 @@ EBE5502760CF6CA2D7201883 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = ""; }; ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomCell.swift; sourceTree = ""; }; ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = ""; }; - ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; path = message.caf; sourceTree = ""; }; + ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = message.caf; sourceTree = ""; }; EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModelTests.swift; sourceTree = ""; }; EDB6E40BAD4504D899FAAC9A /* TemplateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateViewModel.swift; sourceTree = ""; }; EE8BCD14EFED23459A43FDFF /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; @@ -1629,6 +1629,7 @@ 804F9B0FABE093C7284CD09B /* TimelineItemList.swift */, 0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */, 874A1842477895F199567BD7 /* TimelineView.swift */, + C2DE30233B57761F8AFEB415 /* TimelineScrollView.swift */, A312471EA62EFB0FD94E60DC /* Style */, CCD48459CA34A1928EC7A26A /* Supplementary */, B7D3886505ECC85A06DA8258 /* Timeline */, @@ -1989,7 +1990,6 @@ 6A580295A56B55A856CC4084 /* InfoPlistReader.swift */, 6AD1A853D605C2146B0DC028 /* MatrixEntityRegex.swift */, F754E66A8970963B15B2A41E /* PermalinkBuilder.swift */, - C2DE30233B57761F8AFEB415 /* ReversedScrollView.swift */, BB3073CCD77D906B330BC1D6 /* Tests.swift */, 1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */, 44BBB96FAA2F0D53C507396B /* Extensions */, @@ -2889,7 +2889,7 @@ 00EA14F62DCEF62CDE4808D6 /* RedactedRoomTimelineItem.swift in Sources */, 13853973A5E24374FCEDE8A3 /* RedactedRoomTimelineView.swift in Sources */, A494741843F087881299ACF0 /* RestorationToken.swift in Sources */, - BFD1AC03B6F8C5F5897D5B55 /* ReversedScrollView.swift in Sources */, + BFD1AC03B6F8C5F5897D5B55 /* TimelineScrollView.swift in Sources */, 04A16B45228F7678A027C079 /* RoomHeaderView.swift in Sources */, FE79E2BCCF69E8BF4D21E15A /* RoomMessageFactory.swift in Sources */, 8D9F646387DF656EF91EE4CB /* RoomMessageFactoryProtocol.swift in Sources */, diff --git a/ElementX/Sources/Other/ReversedScrollView.swift b/ElementX/Sources/Other/ReversedScrollView.swift deleted file mode 100644 index 98a4bfbab..000000000 --- a/ElementX/Sources/Other/ReversedScrollView.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// Copyright 2022 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import SwiftUI - -/// A SwiftUI scroll view that lays out its content starting at the bottom or trailing -/// https://www.thirdrocktechkno.com/blog/implementing-reversed-scrolling-behaviour-in-swiftui/ -struct ReversedScrollView: View { - private let axis: Axis.Set - private let leadingSpace: CGFloat - private let content: Content - - init(_ axis: Axis.Set = .horizontal, leadingSpace: CGFloat = 0, @ViewBuilder builder: () -> Content) { - self.axis = axis - self.leadingSpace = leadingSpace - content = builder() - } - - var body: some View { - GeometryReader { proxy in - ScrollView(axis, showsIndicators: false) { - Stack(axis) { - Spacer(minLength: leadingSpace) - content - } - .frame( - minWidth: minWidth(in: proxy, for: axis), - minHeight: minHeight(in: proxy, for: axis) - ) - } - } - } - - private func minWidth(in proxy: GeometryProxy, for axis: Axis.Set) -> CGFloat? { - axis.contains(.horizontal) ? proxy.size.width : nil - } - - private func minHeight(in proxy: GeometryProxy, for axis: Axis.Set) -> CGFloat? { - axis.contains(.vertical) ? proxy.size.height : nil - } -} - -private struct Stack: View { - var axis: Axis.Set - var content: Content - - init(_ axis: Axis.Set = .vertical, @ViewBuilder builder: () -> Content) { - self.axis = axis - - content = builder() - } - - var body: some View { - switch axis { - case .horizontal: - HStack { - content - } - case .vertical: - VStack { - content - } - default: - VStack { - content - } - } - } -} diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift index 649d630d0..5b6dd5bfa 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift @@ -38,7 +38,7 @@ struct ImageRoomTimelineView: View { .foregroundColor(.element.systemGray6) .opacity(0.3) - ProgressView("Loading") + ProgressView(ElementL10n.loading) .frame(maxWidth: .infinity) } .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) @@ -46,7 +46,6 @@ struct ImageRoomTimelineView: View { } .id(timelineItem.id) .animation(.elementDefault, value: timelineItem.image) - .frame(maxHeight: 1000.0) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift index f2745cd29..946a65d59 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift @@ -34,7 +34,7 @@ struct VideoRoomTimelineView: View { .foregroundColor(.element.systemGray6) .opacity(0.3) - ProgressView("Loading") + ProgressView(ElementL10n.loading) .frame(maxWidth: .infinity) } .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) @@ -42,7 +42,6 @@ struct VideoRoomTimelineView: View { } .id(timelineItem.id) .animation(.elementDefault, value: timelineItem.image) - .frame(maxHeight: 1000.0) } @ViewBuilder diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift index df7ddaacd..a8d942a8d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift @@ -23,76 +23,74 @@ struct TimelineItemList: View { @State private var timelineItems: [RoomTimelineViewProvider] = [] @State private var viewFrame: CGRect = .zero @State private var pinnedItem: PinnedItem? - @State private var visibleItemIdentifiers: Set = [] - @State private var topVisiblePublisher = CurrentValueSubject(false) + + @Binding var visibleEdges: [VerticalEdge] + /// The last known value of the visible edges. This is stored because `visibleEdges` + /// updates at the same time as the `viewFrame` but we need to know the previous + /// value when the keyboard appears to determine whether to scroll to the bottom. + @State private var cachedVisibleEdges: [VerticalEdge] = [] @EnvironmentObject var context: RoomScreenViewModel.Context - let bottomVisiblePublisher: CurrentValueSubject let scrollToBottomPublisher: PassthroughSubject var body: some View { - ScrollViewReader { proxy in - ReversedScrollView(.vertical) { + ScrollViewReader { scrollView in + TimelineScrollView(visibleEdges: $visibleEdges) { + // The scroll view already contains a VStack so simply provide the content to fill it. + ProgressView() .frame(maxWidth: .infinity) .opacity(context.viewState.isBackPaginating ? 1.0 : 0.0) .animation(.elementDefault, value: context.viewState.isBackPaginating) - LazyVStack(alignment: .leading, spacing: 0.0) { - ForEach(isRunningPreviews ? context.viewState.items : timelineItems) { item in - item - .contextMenu { - context.viewState.contextMenuBuilder?(item.id) - .id(item.id) - } - .opacity(opacityForItem(item)) - .padding(settings.timelineStyle.rowInsets) - .onAppear { - context.send(viewAction: .itemAppeared(id: item.id)) - visibleItemIdentifiers.insert(item.id) - - if timelineItems.first == item { - topVisiblePublisher.send(true) - } - - if timelineItems.last == item { - bottomVisiblePublisher.send(true) - } - } - .onDisappear { - context.send(viewAction: .itemDisappeared(id: item.id)) - visibleItemIdentifiers.remove(item.id) - - if timelineItems.first == item { - topVisiblePublisher.send(false) - } - - if timelineItems.last == item { - bottomVisiblePublisher.send(false) - } - } - .environment(\.openURL, OpenURLAction { url in - context.send(viewAction: .linkClicked(url: url)) - return .systemAction - }) - .onTapGesture { - context.send(viewAction: .itemTapped(id: item.id)) - } - } + ForEach(isRunningPreviews ? context.viewState.items : timelineItems) { item in + item + .contextMenu { + context.viewState.contextMenuBuilder?(item.id) + .id(item.id) + } + .opacity(opacityForItem(item)) + .padding(settings.timelineStyle.rowInsets) + .onAppear { + context.send(viewAction: .itemAppeared(id: item.id)) + } + .onDisappear { + context.send(viewAction: .itemDisappeared(id: item.id)) + } + .environment(\.openURL, OpenURLAction { url in + context.send(viewAction: .linkClicked(url: url)) + return .systemAction + }) + .onTapGesture { + context.send(viewAction: .itemTapped(id: item.id)) + } + } + } + .onChange(of: visibleEdges) { edges in + cachedVisibleEdges = edges + // Paginate when the top becomes visible + guard edges.contains(.top) else { return } + requestBackPagination() + } + .onChange(of: context.viewState.isBackPaginating) { isBackPaginating in + guard !isBackPaginating else { return } + + // Repeat the pagination if the top edge is still visible. + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { + guard visibleEdges.contains(.top) else { return } + requestBackPagination() } } .onChange(of: pinnedItem) { item in - guard let item else { - return - } + guard let item else { return } if item.animated { withAnimation(Animation.elementDefault) { - proxy.scrollTo(item.id, anchor: item.anchor) + scrollView.scrollTo(item.id, anchor: item.anchor) } } else { - proxy.scrollTo(item.id, anchor: item.anchor) + scrollView.scrollTo(item.id, anchor: item.anchor) } pinnedItem = nil @@ -103,20 +101,17 @@ struct TimelineItemList: View { .timelineStyle(settings.timelineStyle) .onAppear { timelineItems = context.viewState.items - requestBackPagination() - } - // Allow SwiftUI to layout the views properly before checking if the top is visible - .onReceive(topVisiblePublisher.collect(.byTime(DispatchQueue.main, 0.5))) { values in - if values.last == true { - requestBackPagination() - } } .onReceive(scrollToBottomPublisher) { scrollToBottom(animated: true) } - .onChange(of: context.viewState.items.count) { _ in - guard !context.viewState.items.isEmpty, - context.viewState.items.count != timelineItems.count else { + .onChange(of: context.viewState.items) { items in + guard + !context.viewState.items.isEmpty, + context.viewState.items.count != timelineItems.count + else { + // Update the items, but don't worry about scrolling if the count is unchanged. + timelineItems = items return } @@ -132,9 +127,7 @@ struct TimelineItemList: View { } // Pin to the new bottom if visible - if let currentLastItem = timelineItems.last, - visibleItemIdentifiers.contains(currentLastItem.id), - let newLastItem = context.viewState.items.last { + if visibleEdges.contains(.bottom), let newLastItem = context.viewState.items.last { let pinnedItem = PinnedItem(id: newLastItem.id, anchor: .bottom, animated: false) timelineItems = context.viewState.items self.pinnedItem = pinnedItem @@ -143,8 +136,7 @@ struct TimelineItemList: View { } // Pin to the old topmost visible - if let currentFirstItem = timelineItems.first, - visibleItemIdentifiers.contains(currentFirstItem.id) { + if visibleEdges.contains(.top), let currentFirstItem = timelineItems.first { let pinnedItem = PinnedItem(id: currentFirstItem.id, anchor: .top, animated: false) timelineItems = context.viewState.items self.pinnedItem = pinnedItem @@ -155,18 +147,10 @@ struct TimelineItemList: View { // Otherwise just update the items timelineItems = context.viewState.items } - .onChange(of: context.viewState.items, perform: { items in - if timelineItems != items { - timelineItems = items - } - }) - .background(GeometryReader { geo in - Color.clear.preference(key: ViewFramePreferenceKey.self, value: [geo.frame(in: .global)]) - }) - .onPreferenceChange(ViewFramePreferenceKey.self) { _ in - guard bottomVisiblePublisher.value == true else { - return - } + .onChange(of: viewFrame) { _ in + // Use the cached version as visibleEdges will already have changed + // (but its onChange handler is yet to be called - possible race condition?) + guard cachedVisibleEdges.contains(.bottom) else { return } // Pin the timeline to the bottom if was there on the frame change DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { @@ -213,14 +197,6 @@ private struct PinnedItem: Equatable { let animated: Bool } -private struct ViewFramePreferenceKey: PreferenceKey { - static var defaultValue = [CGRect]() // Doesn't work with plain CGRects - - static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { - value += nextValue() - } -} - struct TimelineItemList_Previews: PreviewProvider { static var previews: some View { body.preferredColorScheme(.light) @@ -234,7 +210,7 @@ struct TimelineItemList_Previews: PreviewProvider { mediaProvider: MockMediaProvider(), roomName: nil) - TimelineItemList(bottomVisiblePublisher: CurrentValueSubject(false), scrollToBottomPublisher: PassthroughSubject()) + TimelineItemList(visibleEdges: .constant([]), scrollToBottomPublisher: PassthroughSubject()) .environmentObject(viewModel.context) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineScrollView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineScrollView.swift new file mode 100644 index 000000000..29f441476 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineScrollView.swift @@ -0,0 +1,76 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct VisibleEdgesKey: PreferenceKey { + static var defaultValue: [VerticalEdge] = [] + + static func reduce(value: inout [VerticalEdge], nextValue: () -> [VerticalEdge]) { + value = nextValue() + } +} + +/// A SwiftUI scroll view with the following customisations for a room timeline +/// - The content is laid out starting at the bottom. +/// - Top and bottom edge visibility detection for triggering other behaviours. +struct TimelineScrollView: View { + @Binding var visibleEdges: [VerticalEdge] + + @ViewBuilder var content: () -> Content + + /// A small threshold added to the edge detection to allow a bit of leniency. + private let edgeDetectionThreshold: CGFloat = 15 + + var body: some View { + GeometryReader { scrollViewGeometry in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Spacer() + content() + } + .frame(minHeight: scrollViewGeometry.size.height) + .background { + GeometryReader { contentGeometry in + Color.clear + .preference(key: VisibleEdgesKey.self, + value: visibleEdges(of: contentGeometry, in: scrollViewGeometry)) + } + .onPreferenceChange(VisibleEdgesKey.self) { + visibleEdges = $0 + } + } + } + } + } + + func visibleEdges(of contentGeometry: GeometryProxy, in scrollViewGeometry: GeometryProxy) -> [VerticalEdge] { + let frame = contentGeometry.frame(in: .global) + let isTopVisible = scrollViewGeometry.frame(in: .global).contains(CGPoint(x: frame.midX, y: frame.minY + edgeDetectionThreshold)) + let isBottomVisible = scrollViewGeometry.frame(in: .global).contains(CGPoint(x: frame.midX, y: frame.maxY - edgeDetectionThreshold)) + + switch (isTopVisible, isBottomVisible) { + case (false, false): + return [] + case (true, false): + return [.top] + case (false, true): + return [.bottom] + case (true, true): + return [.top, .bottom] + } + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift index 93277ad13..3209e2165 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift @@ -21,31 +21,29 @@ import SwiftUI import Introspect struct TimelineView: View { - @State private var bottomVisiblePublisher = CurrentValueSubject(true) + @State private var visibleEdges: [VerticalEdge] = [] @State private var scrollToBottomPublisher = PassthroughSubject() - @State private var scollToBottomButtonVisible = false + @State private var scrollToBottomButtonVisible = false var body: some View { ZStack(alignment: .bottomTrailing) { - TimelineItemList(bottomVisiblePublisher: bottomVisiblePublisher, scrollToBottomPublisher: scrollToBottomPublisher) + TimelineItemList(visibleEdges: $visibleEdges, scrollToBottomPublisher: scrollToBottomPublisher) scrollToBottomButton } } @ViewBuilder private var scrollToBottomButton: some View { - Button(action: { - scrollToBottomPublisher.send(()) - }, label: { + Button { scrollToBottomPublisher.send(()) } label: { Image(uiImage: Asset.Images.timelineScrollToBottom.image) .shadow(radius: 2.0) .padding() - }) - .onReceive(bottomVisiblePublisher, perform: { visible in - scollToBottomButtonVisible = !visible - }) - .opacity(scollToBottomButtonVisible ? 1.0 : 0.0) - .animation(.elementDefault, value: scollToBottomButtonVisible) + } + .onChange(of: visibleEdges) { edges in + scrollToBottomButtonVisible = !edges.contains(.bottom) + } + .opacity(scrollToBottomButtonVisible ? 1.0 : 0.0) + .animation(.elementDefault, value: scrollToBottomButtonVisible) } } diff --git a/changelog.d/pr-332.change b/changelog.d/pr-332.change new file mode 100644 index 000000000..b0cf4f8f3 --- /dev/null +++ b/changelog.d/pr-332.change @@ -0,0 +1 @@ +Swift from a LazyVStack to a VStack for the timeline. \ No newline at end of file