mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 20:09:04 +00:00
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.
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 51;
|
objectVersion = 54;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -339,7 +339,7 @@
|
|||||||
BB6B0B91CE11E06330017000 /* SessionVerificationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC1A01C3A745BDF1D3697D3 /* SessionVerificationScreen.swift */; };
|
BB6B0B91CE11E06330017000 /* SessionVerificationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC1A01C3A745BDF1D3697D3 /* SessionVerificationScreen.swift */; };
|
||||||
BCEC41FB1F2BB663183863E4 /* LoginServerInfoSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D379E13DD9D987470A3C70C /* LoginServerInfoSection.swift */; };
|
BCEC41FB1F2BB663183863E4 /* LoginServerInfoSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D379E13DD9D987470A3C70C /* LoginServerInfoSection.swift */; };
|
||||||
BFB534E338A3D949944FB2F5 /* NotificationServiceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B490675B8E31423AF116BDA /* NotificationServiceProxy.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 */; };
|
C3522917C0C367C403429EEC /* CoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B251F5B4511D1CA0BA8361FE /* CoordinatorProtocol.swift */; };
|
||||||
C35CF4DAB1467FE1BBDC204B /* MessageTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEAF1C75771D9DC75877F4B4 /* MessageTimelineItem.swift */; };
|
C35CF4DAB1467FE1BBDC204B /* MessageTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEAF1C75771D9DC75877F4B4 /* MessageTimelineItem.swift */; };
|
||||||
C4180F418235DAD9DD173951 /* TemplateScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9873076F224E4CE09D8BD47D /* TemplateScreenUITests.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 = "<group>"; };
|
892E29C98C4E8182C9037F84 /* TimelineStyler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyler.swift; sourceTree = "<group>"; };
|
||||||
8A9AE4967817E9608E22EB44 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
8A9AE4967817E9608E22EB44 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
8AC1A01C3A745BDF1D3697D3 /* SessionVerificationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreen.swift; sourceTree = "<group>"; };
|
8AC1A01C3A745BDF1D3697D3 /* SessionVerificationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreen.swift; sourceTree = "<group>"; };
|
||||||
8B9A55AC2FB0FE0AEAA3DF1F /* LICENSE */ = {isa = PBXFileReference; path = LICENSE; sourceTree = "<group>"; };
|
8B9A55AC2FB0FE0AEAA3DF1F /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
|
||||||
8C0AA893D6F8A2F563E01BB9 /* in */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = in; path = in.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
8C0AA893D6F8A2F563E01BB9 /* in */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = in; path = in.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||||
8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineProvider.swift; sourceTree = "<group>"; };
|
8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineProvider.swift; sourceTree = "<group>"; };
|
||||||
8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
|
8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = "<group>"; };
|
8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = "<group>"; };
|
||||||
8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = "<group>"; };
|
8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITests.xctestplan; sourceTree = "<group>"; };
|
||||||
8ED2D2F6A137A95EA50413BE /* UserNotificationControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationControllerProtocol.swift; sourceTree = "<group>"; };
|
8ED2D2F6A137A95EA50413BE /* UserNotificationControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationControllerProtocol.swift; sourceTree = "<group>"; };
|
||||||
8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummaryProvider.swift; sourceTree = "<group>"; };
|
8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummaryProvider.swift; sourceTree = "<group>"; };
|
||||||
8FC803282F9268D49F4ABF14 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = "<group>"; };
|
8FC803282F9268D49F4ABF14 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = "<group>"; };
|
||||||
@@ -844,7 +844,7 @@
|
|||||||
C070FD43DC6BF4E50217965A /* LocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationTests.swift; sourceTree = "<group>"; };
|
C070FD43DC6BF4E50217965A /* LocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationTests.swift; sourceTree = "<group>"; };
|
||||||
C1198B925F4A88DA74083662 /* OnboardingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewModel.swift; sourceTree = "<group>"; };
|
C1198B925F4A88DA74083662 /* OnboardingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewModel.swift; sourceTree = "<group>"; };
|
||||||
C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenModels.swift; sourceTree = "<group>"; };
|
C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenModels.swift; sourceTree = "<group>"; };
|
||||||
C2DE30233B57761F8AFEB415 /* ReversedScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReversedScrollView.swift; sourceTree = "<group>"; };
|
C2DE30233B57761F8AFEB415 /* TimelineScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineScrollView.swift; sourceTree = "<group>"; };
|
||||||
C3F652E88106B855A2A55ADE /* FilePreviewViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewViewModelProtocol.swift; sourceTree = "<group>"; };
|
C3F652E88106B855A2A55ADE /* FilePreviewViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||||
C444092DB0E4AB393067AC36 /* MediaPlayerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerViewModelTests.swift; sourceTree = "<group>"; };
|
C444092DB0E4AB393067AC36 /* MediaPlayerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerViewModelTests.swift; sourceTree = "<group>"; };
|
||||||
C483956FA3D665E3842E319A /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = "<group>"; };
|
C483956FA3D665E3842E319A /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = "<group>"; };
|
||||||
@@ -928,7 +928,7 @@
|
|||||||
EBE5502760CF6CA2D7201883 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
EBE5502760CF6CA2D7201883 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||||
ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomCell.swift; sourceTree = "<group>"; };
|
ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomCell.swift; sourceTree = "<group>"; };
|
||||||
ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = "<group>"; };
|
ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = "<group>"; };
|
||||||
ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; path = message.caf; sourceTree = "<group>"; };
|
ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = message.caf; sourceTree = "<group>"; };
|
||||||
EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModelTests.swift; sourceTree = "<group>"; };
|
EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModelTests.swift; sourceTree = "<group>"; };
|
||||||
EDB6E40BAD4504D899FAAC9A /* TemplateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateViewModel.swift; sourceTree = "<group>"; };
|
EDB6E40BAD4504D899FAAC9A /* TemplateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateViewModel.swift; sourceTree = "<group>"; };
|
||||||
EE8BCD14EFED23459A43FDFF /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
EE8BCD14EFED23459A43FDFF /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
@@ -1629,6 +1629,7 @@
|
|||||||
804F9B0FABE093C7284CD09B /* TimelineItemList.swift */,
|
804F9B0FABE093C7284CD09B /* TimelineItemList.swift */,
|
||||||
0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */,
|
0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */,
|
||||||
874A1842477895F199567BD7 /* TimelineView.swift */,
|
874A1842477895F199567BD7 /* TimelineView.swift */,
|
||||||
|
C2DE30233B57761F8AFEB415 /* TimelineScrollView.swift */,
|
||||||
A312471EA62EFB0FD94E60DC /* Style */,
|
A312471EA62EFB0FD94E60DC /* Style */,
|
||||||
CCD48459CA34A1928EC7A26A /* Supplementary */,
|
CCD48459CA34A1928EC7A26A /* Supplementary */,
|
||||||
B7D3886505ECC85A06DA8258 /* Timeline */,
|
B7D3886505ECC85A06DA8258 /* Timeline */,
|
||||||
@@ -1989,7 +1990,6 @@
|
|||||||
6A580295A56B55A856CC4084 /* InfoPlistReader.swift */,
|
6A580295A56B55A856CC4084 /* InfoPlistReader.swift */,
|
||||||
6AD1A853D605C2146B0DC028 /* MatrixEntityRegex.swift */,
|
6AD1A853D605C2146B0DC028 /* MatrixEntityRegex.swift */,
|
||||||
F754E66A8970963B15B2A41E /* PermalinkBuilder.swift */,
|
F754E66A8970963B15B2A41E /* PermalinkBuilder.swift */,
|
||||||
C2DE30233B57761F8AFEB415 /* ReversedScrollView.swift */,
|
|
||||||
BB3073CCD77D906B330BC1D6 /* Tests.swift */,
|
BB3073CCD77D906B330BC1D6 /* Tests.swift */,
|
||||||
1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */,
|
1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */,
|
||||||
44BBB96FAA2F0D53C507396B /* Extensions */,
|
44BBB96FAA2F0D53C507396B /* Extensions */,
|
||||||
@@ -2889,7 +2889,7 @@
|
|||||||
00EA14F62DCEF62CDE4808D6 /* RedactedRoomTimelineItem.swift in Sources */,
|
00EA14F62DCEF62CDE4808D6 /* RedactedRoomTimelineItem.swift in Sources */,
|
||||||
13853973A5E24374FCEDE8A3 /* RedactedRoomTimelineView.swift in Sources */,
|
13853973A5E24374FCEDE8A3 /* RedactedRoomTimelineView.swift in Sources */,
|
||||||
A494741843F087881299ACF0 /* RestorationToken.swift in Sources */,
|
A494741843F087881299ACF0 /* RestorationToken.swift in Sources */,
|
||||||
BFD1AC03B6F8C5F5897D5B55 /* ReversedScrollView.swift in Sources */,
|
BFD1AC03B6F8C5F5897D5B55 /* TimelineScrollView.swift in Sources */,
|
||||||
04A16B45228F7678A027C079 /* RoomHeaderView.swift in Sources */,
|
04A16B45228F7678A027C079 /* RoomHeaderView.swift in Sources */,
|
||||||
FE79E2BCCF69E8BF4D21E15A /* RoomMessageFactory.swift in Sources */,
|
FE79E2BCCF69E8BF4D21E15A /* RoomMessageFactory.swift in Sources */,
|
||||||
8D9F646387DF656EF91EE4CB /* RoomMessageFactoryProtocol.swift in Sources */,
|
8D9F646387DF656EF91EE4CB /* RoomMessageFactoryProtocol.swift in Sources */,
|
||||||
|
|||||||
@@ -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<Content: View>: 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<Content: View>: 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -38,7 +38,7 @@ struct ImageRoomTimelineView: View {
|
|||||||
.foregroundColor(.element.systemGray6)
|
.foregroundColor(.element.systemGray6)
|
||||||
.opacity(0.3)
|
.opacity(0.3)
|
||||||
|
|
||||||
ProgressView("Loading")
|
ProgressView(ElementL10n.loading)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
|
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
|
||||||
@@ -46,7 +46,6 @@ struct ImageRoomTimelineView: View {
|
|||||||
}
|
}
|
||||||
.id(timelineItem.id)
|
.id(timelineItem.id)
|
||||||
.animation(.elementDefault, value: timelineItem.image)
|
.animation(.elementDefault, value: timelineItem.image)
|
||||||
.frame(maxHeight: 1000.0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ struct VideoRoomTimelineView: View {
|
|||||||
.foregroundColor(.element.systemGray6)
|
.foregroundColor(.element.systemGray6)
|
||||||
.opacity(0.3)
|
.opacity(0.3)
|
||||||
|
|
||||||
ProgressView("Loading")
|
ProgressView(ElementL10n.loading)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
|
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
|
||||||
@@ -42,7 +42,6 @@ struct VideoRoomTimelineView: View {
|
|||||||
}
|
}
|
||||||
.id(timelineItem.id)
|
.id(timelineItem.id)
|
||||||
.animation(.elementDefault, value: timelineItem.image)
|
.animation(.elementDefault, value: timelineItem.image)
|
||||||
.frame(maxHeight: 1000.0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|||||||
@@ -23,76 +23,74 @@ struct TimelineItemList: View {
|
|||||||
@State private var timelineItems: [RoomTimelineViewProvider] = []
|
@State private var timelineItems: [RoomTimelineViewProvider] = []
|
||||||
@State private var viewFrame: CGRect = .zero
|
@State private var viewFrame: CGRect = .zero
|
||||||
@State private var pinnedItem: PinnedItem?
|
@State private var pinnedItem: PinnedItem?
|
||||||
@State private var visibleItemIdentifiers: Set<String> = []
|
|
||||||
@State private var topVisiblePublisher = CurrentValueSubject<Bool, Never>(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
|
@EnvironmentObject var context: RoomScreenViewModel.Context
|
||||||
|
|
||||||
let bottomVisiblePublisher: CurrentValueSubject<Bool, Never>
|
|
||||||
let scrollToBottomPublisher: PassthroughSubject<Void, Never>
|
let scrollToBottomPublisher: PassthroughSubject<Void, Never>
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { scrollView in
|
||||||
ReversedScrollView(.vertical) {
|
TimelineScrollView(visibleEdges: $visibleEdges) {
|
||||||
|
// The scroll view already contains a VStack so simply provide the content to fill it.
|
||||||
|
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
|
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
|
||||||
.animation(.elementDefault, value: context.viewState.isBackPaginating)
|
.animation(.elementDefault, value: context.viewState.isBackPaginating)
|
||||||
|
|
||||||
LazyVStack(alignment: .leading, spacing: 0.0) {
|
ForEach(isRunningPreviews ? context.viewState.items : timelineItems) { item in
|
||||||
ForEach(isRunningPreviews ? context.viewState.items : timelineItems) { item in
|
item
|
||||||
item
|
.contextMenu {
|
||||||
.contextMenu {
|
context.viewState.contextMenuBuilder?(item.id)
|
||||||
context.viewState.contextMenuBuilder?(item.id)
|
.id(item.id)
|
||||||
.id(item.id)
|
}
|
||||||
}
|
.opacity(opacityForItem(item))
|
||||||
.opacity(opacityForItem(item))
|
.padding(settings.timelineStyle.rowInsets)
|
||||||
.padding(settings.timelineStyle.rowInsets)
|
.onAppear {
|
||||||
.onAppear {
|
context.send(viewAction: .itemAppeared(id: item.id))
|
||||||
context.send(viewAction: .itemAppeared(id: item.id))
|
}
|
||||||
visibleItemIdentifiers.insert(item.id)
|
.onDisappear {
|
||||||
|
context.send(viewAction: .itemDisappeared(id: item.id))
|
||||||
if timelineItems.first == item {
|
}
|
||||||
topVisiblePublisher.send(true)
|
.environment(\.openURL, OpenURLAction { url in
|
||||||
}
|
context.send(viewAction: .linkClicked(url: url))
|
||||||
|
return .systemAction
|
||||||
if timelineItems.last == item {
|
})
|
||||||
bottomVisiblePublisher.send(true)
|
.onTapGesture {
|
||||||
}
|
context.send(viewAction: .itemTapped(id: item.id))
|
||||||
}
|
}
|
||||||
.onDisappear {
|
}
|
||||||
context.send(viewAction: .itemDisappeared(id: item.id))
|
}
|
||||||
visibleItemIdentifiers.remove(item.id)
|
.onChange(of: visibleEdges) { edges in
|
||||||
|
cachedVisibleEdges = edges
|
||||||
if timelineItems.first == item {
|
// Paginate when the top becomes visible
|
||||||
topVisiblePublisher.send(false)
|
guard edges.contains(.top) else { return }
|
||||||
}
|
requestBackPagination()
|
||||||
|
}
|
||||||
if timelineItems.last == item {
|
.onChange(of: context.viewState.isBackPaginating) { isBackPaginating in
|
||||||
bottomVisiblePublisher.send(false)
|
guard !isBackPaginating else { return }
|
||||||
}
|
|
||||||
}
|
// Repeat the pagination if the top edge is still visible.
|
||||||
.environment(\.openURL, OpenURLAction { url in
|
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) {
|
||||||
context.send(viewAction: .linkClicked(url: url))
|
guard visibleEdges.contains(.top) else { return }
|
||||||
return .systemAction
|
requestBackPagination()
|
||||||
})
|
|
||||||
.onTapGesture {
|
|
||||||
context.send(viewAction: .itemTapped(id: item.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: pinnedItem) { item in
|
.onChange(of: pinnedItem) { item in
|
||||||
guard let item else {
|
guard let item else { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.animated {
|
if item.animated {
|
||||||
withAnimation(Animation.elementDefault) {
|
withAnimation(Animation.elementDefault) {
|
||||||
proxy.scrollTo(item.id, anchor: item.anchor)
|
scrollView.scrollTo(item.id, anchor: item.anchor)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
proxy.scrollTo(item.id, anchor: item.anchor)
|
scrollView.scrollTo(item.id, anchor: item.anchor)
|
||||||
}
|
}
|
||||||
|
|
||||||
pinnedItem = nil
|
pinnedItem = nil
|
||||||
@@ -103,20 +101,17 @@ struct TimelineItemList: View {
|
|||||||
.timelineStyle(settings.timelineStyle)
|
.timelineStyle(settings.timelineStyle)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
timelineItems = context.viewState.items
|
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) {
|
.onReceive(scrollToBottomPublisher) {
|
||||||
scrollToBottom(animated: true)
|
scrollToBottom(animated: true)
|
||||||
}
|
}
|
||||||
.onChange(of: context.viewState.items.count) { _ in
|
.onChange(of: context.viewState.items) { items in
|
||||||
guard !context.viewState.items.isEmpty,
|
guard
|
||||||
context.viewState.items.count != timelineItems.count else {
|
!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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,9 +127,7 @@ struct TimelineItemList: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pin to the new bottom if visible
|
// Pin to the new bottom if visible
|
||||||
if let currentLastItem = timelineItems.last,
|
if visibleEdges.contains(.bottom), let newLastItem = context.viewState.items.last {
|
||||||
visibleItemIdentifiers.contains(currentLastItem.id),
|
|
||||||
let newLastItem = context.viewState.items.last {
|
|
||||||
let pinnedItem = PinnedItem(id: newLastItem.id, anchor: .bottom, animated: false)
|
let pinnedItem = PinnedItem(id: newLastItem.id, anchor: .bottom, animated: false)
|
||||||
timelineItems = context.viewState.items
|
timelineItems = context.viewState.items
|
||||||
self.pinnedItem = pinnedItem
|
self.pinnedItem = pinnedItem
|
||||||
@@ -143,8 +136,7 @@ struct TimelineItemList: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pin to the old topmost visible
|
// Pin to the old topmost visible
|
||||||
if let currentFirstItem = timelineItems.first,
|
if visibleEdges.contains(.top), let currentFirstItem = timelineItems.first {
|
||||||
visibleItemIdentifiers.contains(currentFirstItem.id) {
|
|
||||||
let pinnedItem = PinnedItem(id: currentFirstItem.id, anchor: .top, animated: false)
|
let pinnedItem = PinnedItem(id: currentFirstItem.id, anchor: .top, animated: false)
|
||||||
timelineItems = context.viewState.items
|
timelineItems = context.viewState.items
|
||||||
self.pinnedItem = pinnedItem
|
self.pinnedItem = pinnedItem
|
||||||
@@ -155,18 +147,10 @@ struct TimelineItemList: View {
|
|||||||
// Otherwise just update the items
|
// Otherwise just update the items
|
||||||
timelineItems = context.viewState.items
|
timelineItems = context.viewState.items
|
||||||
}
|
}
|
||||||
.onChange(of: context.viewState.items, perform: { items in
|
.onChange(of: viewFrame) { _ in
|
||||||
if timelineItems != items {
|
// Use the cached version as visibleEdges will already have changed
|
||||||
timelineItems = items
|
// (but its onChange handler is yet to be called - possible race condition?)
|
||||||
}
|
guard cachedVisibleEdges.contains(.bottom) else { return }
|
||||||
})
|
|
||||||
.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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pin the timeline to the bottom if was there on the frame change
|
// Pin the timeline to the bottom if was there on the frame change
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
@@ -213,14 +197,6 @@ private struct PinnedItem: Equatable {
|
|||||||
let animated: Bool
|
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 {
|
struct TimelineItemList_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
body.preferredColorScheme(.light)
|
body.preferredColorScheme(.light)
|
||||||
@@ -234,7 +210,7 @@ struct TimelineItemList_Previews: PreviewProvider {
|
|||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
roomName: nil)
|
roomName: nil)
|
||||||
|
|
||||||
TimelineItemList(bottomVisiblePublisher: CurrentValueSubject(false), scrollToBottomPublisher: PassthroughSubject())
|
TimelineItemList(visibleEdges: .constant([]), scrollToBottomPublisher: PassthroughSubject())
|
||||||
.environmentObject(viewModel.context)
|
.environmentObject(viewModel.context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Content: View>: 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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,31 +21,29 @@ import SwiftUI
|
|||||||
import Introspect
|
import Introspect
|
||||||
|
|
||||||
struct TimelineView: View {
|
struct TimelineView: View {
|
||||||
@State private var bottomVisiblePublisher = CurrentValueSubject<Bool, Never>(true)
|
@State private var visibleEdges: [VerticalEdge] = []
|
||||||
@State private var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
|
@State private var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
|
||||||
@State private var scollToBottomButtonVisible = false
|
@State private var scrollToBottomButtonVisible = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
TimelineItemList(bottomVisiblePublisher: bottomVisiblePublisher, scrollToBottomPublisher: scrollToBottomPublisher)
|
TimelineItemList(visibleEdges: $visibleEdges, scrollToBottomPublisher: scrollToBottomPublisher)
|
||||||
scrollToBottomButton
|
scrollToBottomButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var scrollToBottomButton: some View {
|
private var scrollToBottomButton: some View {
|
||||||
Button(action: {
|
Button { scrollToBottomPublisher.send(()) } label: {
|
||||||
scrollToBottomPublisher.send(())
|
|
||||||
}, label: {
|
|
||||||
Image(uiImage: Asset.Images.timelineScrollToBottom.image)
|
Image(uiImage: Asset.Images.timelineScrollToBottom.image)
|
||||||
.shadow(radius: 2.0)
|
.shadow(radius: 2.0)
|
||||||
.padding()
|
.padding()
|
||||||
})
|
}
|
||||||
.onReceive(bottomVisiblePublisher, perform: { visible in
|
.onChange(of: visibleEdges) { edges in
|
||||||
scollToBottomButtonVisible = !visible
|
scrollToBottomButtonVisible = !edges.contains(.bottom)
|
||||||
})
|
}
|
||||||
.opacity(scollToBottomButtonVisible ? 1.0 : 0.0)
|
.opacity(scrollToBottomButtonVisible ? 1.0 : 0.0)
|
||||||
.animation(.elementDefault, value: scollToBottomButtonVisible)
|
.animation(.elementDefault, value: scrollToBottomButtonVisible)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Swift from a LazyVStack to a VStack for the timeline.
|
||||||
Reference in New Issue
Block a user