mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 21:39:12 +00:00
Add expand/collapse UI for reactions (#1249)
* Add expand/collapse UI for reactions - Adds a CollapsibleFlowLayout for controlling the layout - Adds tests for this layout and some mocks for testing layouts generally - Improves the rendering of the reaction buttons which were not pixel perfect - Adds the UI for the expand collapse buttons including the count of hidden items in the collapsed state. * Add comment for reactionsCollapsed binding. * Remove Flow and simplify implementation - Remove SwiftUI-Flow - Add strings by importing from Localyse - Remove count on expand button as requires GeometryReader and can cause loops - Don't use GeometryReader for hiding reactions with opacity(just put them way off screen for now) - Fix unit and UI tests * Address PR comments - use synthesized inits - use rows rather than lines for naming flow layout - other naming improvements - reactions were already rendered in another ui test, removing my test on favour of those and updating the screenshots for those.
This commit is contained in:
parent
c453cc0680
commit
2cec1858ff
@ -147,7 +147,6 @@
|
||||
368C8758FCD079E6AAA18C2C /* NoticeRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B243E7818E5E9F6A4EDC7A /* NoticeRoomTimelineView.swift */; };
|
||||
36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6D88E8AFFBF2C1D589C0FA /* UIConstants.swift */; };
|
||||
36AD4DD4C798E22584ED3200 /* Version in Frameworks */ = {isa = PBXBuildFile; productRef = A05AF81DDD14AD58CB0E1B9B /* Version */; };
|
||||
36CD6E11B37396E14F032CB6 /* Flow in Frameworks */ = {isa = PBXBuildFile; productRef = 4D7E6BFC89715FC3CF0349D0 /* Flow */; };
|
||||
37D789F24199B32E3FD1AA7B /* FileRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 216F0DDC98F2A2C162D09C28 /* FileRoomTimelineItemContent.swift */; };
|
||||
383055C6ABE5BE058CEE1DDB /* WelcomeScreenScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FE5EF0AFFE360C66420AAE /* WelcomeScreenScreenCoordinator.swift */; };
|
||||
38546A6010A2CF240EC9AF73 /* BindableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA1D2CBAEA5D0BD00B90D1B /* BindableState.swift */; };
|
||||
@ -504,6 +503,7 @@
|
||||
B245583C63F8F90357B87FAE /* KZFileWatchers in Frameworks */ = {isa = PBXBuildFile; productRef = A2AE110B053B55E38F8D10C7 /* KZFileWatchers */; };
|
||||
B27D3190784F85916DA1C394 /* SessionVerificationScreenStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B1EE0908B2BF9212436AD3E /* SessionVerificationScreenStateMachine.swift */; };
|
||||
B2F8E01ABA1BA30265B4ECBE /* RoundedCornerShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 839E2C35DF3F9C7B54C3CE49 /* RoundedCornerShape.swift */; };
|
||||
B3490F3DB563A543C73CD663 /* CollapsibleFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD349FC7BB07EFCA64851D2 /* CollapsibleFlowLayout.swift */; };
|
||||
B3D652AA1654270742072FB3 /* DeveloperOptionsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A6F283BC574FDB96ABBB07 /* DeveloperOptionsScreenViewModel.swift */; };
|
||||
B3EDDEC1839BB5A3747624BB /* FormButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95A1CCDEE545CB6453B084BF /* FormButtonStyles.swift */; };
|
||||
B402708F8728DD0DB7C324E2 /* StartChatScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78910787F967CBC6042A101E /* StartChatScreenViewModelProtocol.swift */; };
|
||||
@ -512,6 +512,7 @@
|
||||
B46EBC7B96CCB64FF8E110DC /* MigrationScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CD0FAE9EA761DA175D31CC7 /* MigrationScreenModels.swift */; };
|
||||
B4A0C69370E6008A971463E7 /* BugReportScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C89820BB2B88D4EA28131C /* BugReportScreenViewModelProtocol.swift */; };
|
||||
B4AAB3257A83B73F53FB2689 /* StateStoreViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3DFE5B444F131648066F05 /* StateStoreViewModel.swift */; };
|
||||
B5321A1F5B26A0F3EC54909E /* CollapsibleFlowLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5F5209279A752D98AAC4B2 /* CollapsibleFlowLayoutTests.swift */; };
|
||||
B5479997ECC516C121E6625E /* LocationMarkerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFECCE59967018204876D0A5 /* LocationMarkerView.swift */; };
|
||||
B5903E48CF43259836BF2DBF /* EncryptedRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56C1BCB9E83B09A45387FCA2 /* EncryptedRoomTimelineView.swift */; };
|
||||
B5E455C9689EA600EDB3E9E0 /* NavigationRootCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA28F29C9F93E93CC3C2C715 /* NavigationRootCoordinator.swift */; };
|
||||
@ -584,6 +585,7 @@
|
||||
CB99B0FA38A4AC596F38CC13 /* KeychainControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E94DCFEE803E5ABAE8ACCE /* KeychainControllerProtocol.swift */; };
|
||||
CBA9EDF305036039166E76FF /* StartChatScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEC1AB349A341FE13DEC1 /* StartChatScreenUITests.swift */; };
|
||||
CC0D088F505F33A20DC5590F /* RoomStateEventStringBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEEAFB646E583655652C3D04 /* RoomStateEventStringBuilderTests.swift */; };
|
||||
CC961529F9F1854BEC3272C9 /* LayoutMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8AA23D4F37CC26564F63C5 /* LayoutMocks.swift */; };
|
||||
CCAA0671B46EAFD0BB528E2C /* apple_emojis_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8FC26871038FB0E4AAE22605 /* apple_emojis_data.json */; };
|
||||
CCBEC2100CAF2EEBE9DB4156 /* TemplateScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA40B98B098B6F0371B750B3 /* TemplateScreenModels.swift */; };
|
||||
CCC3802A3C019A6FFAAA547A /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E65E613F057697A1A0BC03 /* NotificationViewController.swift */; };
|
||||
@ -1225,6 +1227,7 @@
|
||||
ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
AC1DA29A5A041CC0BACA7CB0 /* MockImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockImageCache.swift; sourceTree = "<group>"; };
|
||||
AC3F82523D6F48B926D6AF68 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
|
||||
AC5F5209279A752D98AAC4B2 /* CollapsibleFlowLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleFlowLayoutTests.swift; sourceTree = "<group>"; };
|
||||
ACCC1874C122E2BBE648B8F5 /* LegalInformationScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenUITests.swift; sourceTree = "<group>"; };
|
||||
AD378D580A41E42560C60E9C /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
ADB35E2DB4EFE8E6F3959629 /* InviteUsersScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenUITests.swift; sourceTree = "<group>"; };
|
||||
@ -1271,12 +1274,14 @@
|
||||
BB3073CCD77D906B330BC1D6 /* Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests.swift; sourceTree = "<group>"; };
|
||||
BB33A751BFDA223BDD106EC0 /* OnboardingModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingModels.swift; sourceTree = "<group>"; };
|
||||
BB8BC4C791D0E88CFCF4E5DF /* ServerSelectionScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
BC8AA23D4F37CC26564F63C5 /* LayoutMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutMocks.swift; sourceTree = "<group>"; };
|
||||
BE148A4FFEE853C5A281500C /* UNNotificationContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UNNotificationContent.swift; sourceTree = "<group>"; };
|
||||
BE6C10032A77AE7DC5AA4C50 /* MessageComposerTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposerTextField.swift; sourceTree = "<group>"; };
|
||||
BE89A8BD65CCE3FCC925CA14 /* TimelineItemReplyDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemReplyDetails.swift; sourceTree = "<group>"; };
|
||||
BEA38B9851CFCC4D67F5587D /* EmojiPickerScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
BEBA759D1347CFFB3D84ED1F /* UserSessionStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionStoreProtocol.swift; sourceTree = "<group>"; };
|
||||
BF34A2FD6797535C95AC918D /* PlaceholderScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
BFD349FC7BB07EFCA64851D2 /* CollapsibleFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleFlowLayout.swift; sourceTree = "<group>"; };
|
||||
BFDCAC6CAAD65A2C24EA9C4B /* Dictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dictionary.swift; sourceTree = "<group>"; };
|
||||
BFEA446F8618DBA79A9239CC /* MessageForwardingScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreen.swift; sourceTree = "<group>"; };
|
||||
C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXAttributeScope.swift; sourceTree = "<group>"; };
|
||||
@ -1525,7 +1530,6 @@
|
||||
754602A7B2AAD443C4228ED4 /* Sentry in Frameworks */,
|
||||
B0CB16349B96262AA65A04AF /* URLRouting in Frameworks */,
|
||||
36AD4DD4C798E22584ED3200 /* Version in Frameworks */,
|
||||
36CD6E11B37396E14F032CB6 /* Flow in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -2445,6 +2449,7 @@
|
||||
851EF6258DF8B7EF129DC3AC /* WelcomeScreenScreenViewModelTests.swift */,
|
||||
53280D2292E6C9C7821773FD /* UserSession */,
|
||||
70C5B842301AC281DF374E41 /* Extensions */,
|
||||
A6AA0A048CAE428A5CA4CBBB /* LayoutTests */,
|
||||
7583EAC171059A86B767209F /* MediaProvider */,
|
||||
7DBC911559934065993A5FF4 /* NotificationManager */,
|
||||
1C62F5382CC9D9F7DCEC344A /* UserDiscoveryService */,
|
||||
@ -2851,6 +2856,14 @@
|
||||
path = View;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9F7C2D63C42828D8931D5286 /* CollapsibleFlowLayout */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BFD349FC7BB07EFCA64851D2 /* CollapsibleFlowLayout.swift */,
|
||||
);
|
||||
path = CollapsibleFlowLayout;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9FD8D798D879069243A7E7F7 /* View */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@ -2923,6 +2936,15 @@
|
||||
path = WaitlistScreen;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A6AA0A048CAE428A5CA4CBBB /* LayoutTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AC5F5209279A752D98AAC4B2 /* CollapsibleFlowLayoutTests.swift */,
|
||||
BC8AA23D4F37CC26564F63C5 /* LayoutMocks.swift */,
|
||||
);
|
||||
path = LayoutTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A78C2592419CA4C76FBA8FD2 /* Application */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@ -3132,6 +3154,7 @@
|
||||
1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */,
|
||||
35FA991289149D31F4286747 /* UserPreference.swift */,
|
||||
7431C962E314ADAE38B6D708 /* Analytics */,
|
||||
9F7C2D63C42828D8931D5286 /* CollapsibleFlowLayout */,
|
||||
349FE0C25B41C7AC9B7C623F /* EffectsScene */,
|
||||
44BBB96FAA2F0D53C507396B /* Extensions */,
|
||||
8F9A844EB44B6AD7CA18FD96 /* HTMLParsing */,
|
||||
@ -3627,7 +3650,6 @@
|
||||
7731767AE437BA3BD2CC14A8 /* Sentry */,
|
||||
E9BAB8A793FE3B54CDD47102 /* URLRouting */,
|
||||
A05AF81DDD14AD58CB0E1B9B /* Version */,
|
||||
4D7E6BFC89715FC3CF0349D0 /* Flow */,
|
||||
);
|
||||
productName = ElementX;
|
||||
productReference = 4CD6AC7546E8D7E5C73CEA48 /* ElementX.app */;
|
||||
@ -3741,7 +3763,6 @@
|
||||
9754C4B03F6255F67FC15E52 /* XCRemoteSwiftPackageReference "compound-ios" */,
|
||||
C13F55E4518415CB4C278E73 /* XCRemoteSwiftPackageReference "DTCoreText" */,
|
||||
D5F7D47BBAAE0CF1DDEB3034 /* XCRemoteSwiftPackageReference "DeviceKit" */,
|
||||
65398401562A467FD2FDCF20 /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */,
|
||||
701C7BEF8F70F7A83E852DCC /* XCRemoteSwiftPackageReference "GZIP" */,
|
||||
9A472EE0218FE7DCF5283429 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
|
||||
395DE6AE429B7ACC7C7FE31D /* XCRemoteSwiftPackageReference "KZFileWatchers" */,
|
||||
@ -3980,6 +4001,7 @@
|
||||
0F9E38A75337D0146652ACAB /* BackgroundTaskTests.swift in Sources */,
|
||||
7F61F9ACD5EC9E845EF3EFBF /* BugReportServiceTests.swift in Sources */,
|
||||
C7CFDB4929DDD9A3B5BA085D /* BugReportViewModelTests.swift in Sources */,
|
||||
B5321A1F5B26A0F3EC54909E /* CollapsibleFlowLayoutTests.swift in Sources */,
|
||||
D3FD96913D2B1AAA3149DAC7 /* CreateRoomViewModelTests.swift in Sources */,
|
||||
CD0088B763CD970CF1CBF8CB /* DateTests.swift in Sources */,
|
||||
864C69CF951BF36D25BE0C03 /* DeveloperOptionsScreenViewModelTests.swift in Sources */,
|
||||
@ -3994,6 +4016,7 @@
|
||||
A216C83ADCF32BA5EF8A6FBC /* InviteUsersViewModelTests.swift in Sources */,
|
||||
266C4DF893F2947DCCEF327B /* InvitesScreenViewModelTests.swift in Sources */,
|
||||
EEC40663922856C65D1E0DF5 /* KeychainControllerTests.swift in Sources */,
|
||||
CC961529F9F1854BEC3272C9 /* LayoutMocks.swift in Sources */,
|
||||
8AC256AF0EC54658321C9241 /* LegalInformationScreenViewModelTests.swift in Sources */,
|
||||
0033481EE363E4914295F188 /* LocalizationTests.swift in Sources */,
|
||||
664F77F02A57617A00FB9B24 /* XCTestCase.swift in Sources */,
|
||||
@ -4130,6 +4153,7 @@
|
||||
520EEDAFBC778AB0B41F2F53 /* ClientMock.swift in Sources */,
|
||||
1950A80CD198BED283DFC2CE /* ClientProxy.swift in Sources */,
|
||||
24BDDD09A90B8BFE3793F3AA /* ClientProxyProtocol.swift in Sources */,
|
||||
B3490F3DB563A543C73CD663 /* CollapsibleFlowLayout.swift in Sources */,
|
||||
9FAF6DA7E8E85C9699757764 /* CollapsibleRoomTimelineView.swift in Sources */,
|
||||
0DC815CA24E1BD7F408F37D3 /* CollapsibleTimelineItem.swift in Sources */,
|
||||
663E198678778F7426A9B27D /* Collection.swift in Sources */,
|
||||
@ -5178,14 +5202,6 @@
|
||||
minimumVersion = 4.2.0;
|
||||
};
|
||||
};
|
||||
65398401562A467FD2FDCF20 /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/tevelee/SwiftUI-Flow";
|
||||
requirement = {
|
||||
kind = upToNextMinorVersion;
|
||||
minimumVersion = 1.2.0;
|
||||
};
|
||||
};
|
||||
6582B5AF3F104B0F7E031E7D /* XCRemoteSwiftPackageReference "SwiftState" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/ReactKit/SwiftState";
|
||||
@ -5384,11 +5400,6 @@
|
||||
package = 96495DD8554E2F39D3954354 /* XCRemoteSwiftPackageReference "posthog-ios" */;
|
||||
productName = PostHog;
|
||||
};
|
||||
4D7E6BFC89715FC3CF0349D0 /* Flow */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 65398401562A467FD2FDCF20 /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */;
|
||||
productName = Flow;
|
||||
};
|
||||
50009897F60FAE7D63EF5E5B /* Kingfisher */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = D283517192CAC3E2E6920765 /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||
|
@ -142,15 +142,6 @@
|
||||
"version" : "1.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-argument-parser",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-argument-parser",
|
||||
"state" : {
|
||||
"revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a",
|
||||
"version" : "1.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-case-paths",
|
||||
"kind" : "remoteSourceControl",
|
||||
@ -214,15 +205,6 @@
|
||||
"version" : "6.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftui-flow",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/tevelee/SwiftUI-Flow",
|
||||
"state" : {
|
||||
"revision" : "d592b610a92869b74ede8ee4c0435a29219be9d8",
|
||||
"version" : "1.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftui-introspect",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
@ -50,7 +50,15 @@ extension AggregatedReaction {
|
||||
AggregatedReaction(accountOwnerID: alice, key: "🚀", senders: [alice] + mockIds(3)),
|
||||
AggregatedReaction(accountOwnerID: alice, key: "😇", senders: mockIds(2)),
|
||||
AggregatedReaction(accountOwnerID: alice, key: "🤭", senders: [alice] + mockIds(8)),
|
||||
AggregatedReaction(accountOwnerID: alice, key: "🫤", senders: mockIds(10))
|
||||
AggregatedReaction(accountOwnerID: alice, key: "🫤", senders: mockIds(10)),
|
||||
AggregatedReaction(accountOwnerID: alice, key: "🐶", senders: mockIds(1)),
|
||||
AggregatedReaction(accountOwnerID: alice, key: "🐱", senders: mockIds(1)),
|
||||
AggregatedReaction(accountOwnerID: alice, key: "🐭", senders: mockIds(1)),
|
||||
AggregatedReaction(accountOwnerID: alice, key: "🐹", senders: mockIds(1)),
|
||||
AggregatedReaction(accountOwnerID: alice, key: "🐰", senders: mockIds(1)),
|
||||
AggregatedReaction(accountOwnerID: alice, key: "🦊", senders: mockIds(1)),
|
||||
AggregatedReaction(accountOwnerID: alice, key: "🐻", senders: mockIds(1)),
|
||||
AggregatedReaction(accountOwnerID: alice, key: "🐼", senders: mockIds(1))
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,203 @@
|
||||
//
|
||||
// Copyright 2023 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 flow layout that will show a collapse/expand button when the layout wraps over a defined number of rows.
|
||||
/// With n subviews passed to the layout, n-1 first views represent the main views to be laid out.
|
||||
/// The nth subview is the collapse/expand button which is only shown when the layout overflows `rowsBeforeCollapsible` number of rows.
|
||||
/// When the button is shown it is tagged on the end of the collapsed or expanded layout.
|
||||
struct CollapsibleFlowLayout: Layout {
|
||||
static let pointOffscreen = CGPoint(x: -10000, y: -10000)
|
||||
/// The horizontal spacing between items
|
||||
var itemSpacing: CGFloat = 0
|
||||
/// The vertical spacing between rows
|
||||
var rowSpacing: CGFloat = 0
|
||||
/// Whether the layout should display in expanded or collapsed state
|
||||
var collapsed = true
|
||||
/// The number of rows before the collapse/expand button is shown
|
||||
var rowsBeforeCollapsible: Int?
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: some FlowLayoutSubviews, cache: inout ()) -> CGSize {
|
||||
let collapseButton = subviews[subviews.count - 1]
|
||||
var subviewsWithoutCollapseButton = subviews
|
||||
subviewsWithoutCollapseButton.removeLast()
|
||||
// Calculate the layout of the rows without the button
|
||||
let rowsNoButton = calculateRows(proposal: proposal, subviews: Array(subviewsWithoutCollapseButton))
|
||||
|
||||
// If we have extended beyond the defined number of rows we are showing the expand/collapse ui
|
||||
if let rowsBeforeCollapsible, rowsNoButton.count > rowsBeforeCollapsible {
|
||||
if collapsed {
|
||||
// Truncate to `rowsBeforeCollapsible` number of rows and replace the item at the end of the last row with the button
|
||||
let collapsedRows = Array(rowsNoButton.prefix(rowsBeforeCollapsible))
|
||||
let (collapsedRowsWithButton, _) = replaceTrailingItemsWithButton(rowWidth: proposal.width ?? 0, rows: collapsedRows, button: collapseButton)
|
||||
let size = sizeThatFits(proposal: proposal, rows: collapsedRowsWithButton)
|
||||
return size
|
||||
} else {
|
||||
// Show all subviews with the button at the end
|
||||
let rowsWithButton = calculateRows(proposal: proposal, subviews: Array(subviews))
|
||||
let size = sizeThatFits(proposal: proposal, rows: rowsWithButton)
|
||||
return size
|
||||
}
|
||||
} else {
|
||||
// Otherwise we are just calculating the size of all items without the button
|
||||
return sizeThatFits(proposal: proposal, rows: rowsNoButton)
|
||||
}
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: some FlowLayoutSubviews, cache: inout ()) {
|
||||
let collapseButton = subviews[subviews.count - 1]
|
||||
var subviewsWithoutCollapseButton = subviews
|
||||
subviewsWithoutCollapseButton.removeLast()
|
||||
// Calculate the layout of the rows without the button
|
||||
let rowsNoButton = calculateRows(proposal: ProposedViewSize(bounds.size), subviews: Array(subviewsWithoutCollapseButton))
|
||||
// If we have extended beyond the defined number of rows we are showing the expand/collapse ui
|
||||
if let rowsBeforeCollapsible, rowsNoButton.count > rowsBeforeCollapsible {
|
||||
if collapsed {
|
||||
// Truncate to `rowsBeforeCollapsible` number of rows and replace the item at the end of the last row with the button
|
||||
let collapsedRows = Array(rowsNoButton.prefix(rowsBeforeCollapsible))
|
||||
let (collapsedRowsWithButton, subviewsToHide) = replaceTrailingItemsWithButton(rowWidth: bounds.width, rows: collapsedRows, button: collapseButton)
|
||||
let remainingSubviews = subviewsToHide + Array(rowsNoButton.suffix(rowsNoButton.count - rowsBeforeCollapsible)).joined()
|
||||
placeSubviews(in: bounds, rows: collapsedRowsWithButton)
|
||||
// "Remove" (place with a proposed zero frame) any additional subviews
|
||||
remainingSubviews.forEach { subview in
|
||||
subview.place(at: Self.pointOffscreen, anchor: .leading, proposal: .zero)
|
||||
}
|
||||
|
||||
} else {
|
||||
// Show all subviews with the button at the end
|
||||
let rowsWithButton = calculateRows(proposal: ProposedViewSize(bounds.size), subviews: Array(subviews))
|
||||
placeSubviews(in: bounds, rows: rowsWithButton)
|
||||
}
|
||||
} else {
|
||||
// Otherwise we are just calculating the size of all items without the button
|
||||
placeSubviews(in: bounds, rows: rowsNoButton)
|
||||
// "Remove"(place with a proposed zero frame) the button
|
||||
collapseButton.place(at: Self.pointOffscreen, anchor: .leading, proposal: .zero)
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a proposed size and a flat list of subviews, calculates and returns a structure representing
|
||||
/// how the subviews should wrap on to multiple rows given the size's width.
|
||||
/// - Parameters:
|
||||
/// - proposal: The proposed size
|
||||
/// - subviews: The subviews
|
||||
/// - Returns: A 2d array, the first dimension representing the rows, the second being the items per row.
|
||||
private func calculateRows(proposal: ProposedViewSize, subviews: [FlowLayoutSubview]) -> [[FlowLayoutSubview]] {
|
||||
var rows = [[FlowLayoutSubview]]()
|
||||
var currentRow = [FlowLayoutSubview]()
|
||||
|
||||
var rowX: CGFloat = 0
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
let horizontalSpacing = currentRow.isEmpty ? 0 : itemSpacing
|
||||
// If the current view does not fine on this row bump to the next
|
||||
if rowX + size.width > proposal.width ?? 0 {
|
||||
rows.append(currentRow)
|
||||
currentRow = [LayoutSubview]()
|
||||
rowX = 0
|
||||
}
|
||||
rowX += horizontalSpacing + size.width
|
||||
currentRow.append(subview)
|
||||
}
|
||||
// If there are items in the current row remember to append it to the returned value
|
||||
if currentRow.count > 0 {
|
||||
rows.append(currentRow)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/// Given a list of rows calculate the size needed to display them
|
||||
/// - Parameters:
|
||||
/// - proposal: The proposed size
|
||||
/// - rows: The list of rows
|
||||
/// - Returns: The size render the rows
|
||||
private func sizeThatFits(proposal: ProposedViewSize, rows: [[FlowLayoutSubview]]) -> CGSize {
|
||||
rows.enumerated().reduce(CGSize.zero) { partialResult, rowItem in
|
||||
let (rowIndex, row) = rowItem
|
||||
let rowSize = row.enumerated().reduce(CGSize.zero) { partialResult, subviewItem in
|
||||
let (subviewIndex, subview) = subviewItem
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
let horizontalSpacing = subviewIndex == 0 ? 0 : itemSpacing
|
||||
return CGSize(width: partialResult.width + size.width + horizontalSpacing, height: max(partialResult.height, size.height))
|
||||
}
|
||||
let verticalSpacing = rowIndex == 0 ? 0 : rowSpacing
|
||||
return CGSize(width: max(partialResult.width, rowSize.width), height: partialResult.height + rowSize.height + verticalSpacing)
|
||||
}
|
||||
}
|
||||
|
||||
/// Used to render the collapsed state, this takes the rows inputted and adds the button to the last row,
|
||||
/// removing only as many trailing subviews as needed to make space for it. It also returns the items removed.
|
||||
/// - Parameters:
|
||||
/// - rowWidth: The width of the parent
|
||||
/// - rows: The input list of rows
|
||||
/// - button: The button to replace the trailing items
|
||||
/// - Returns: The new rows structure with button replaced and the subviews remove from the input to make space for the button
|
||||
private func replaceTrailingItemsWithButton(rowWidth: CGFloat, rows: [[FlowLayoutSubview]], button: FlowLayoutSubview) -> ([[FlowLayoutSubview]], [FlowLayoutSubview]) {
|
||||
var rows = rows
|
||||
let lastRow = rows[rows.count - 1]
|
||||
let buttonSize = button.sizeThatFits(.unspecified)
|
||||
var rowX: CGFloat = 0
|
||||
for (i, subview) in lastRow.enumerated() {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
let horizontalSpacing = i == 0 ? 0 : itemSpacing
|
||||
rowX += size.width + horizontalSpacing
|
||||
if rowX > (rowWidth - (buttonSize.width + horizontalSpacing)) {
|
||||
let lastRowWithButton = Array(lastRow.prefix(i)) + [button]
|
||||
let subviewsToHide = Array(lastRow.suffix(lastRow.count - i))
|
||||
rows[rows.count - 1] = lastRowWithButton
|
||||
return (rows, subviewsToHide)
|
||||
}
|
||||
}
|
||||
let lastRowWithButton = Array(lastRow) + [button]
|
||||
rows[rows.count - 1] = lastRowWithButton
|
||||
return (rows, [])
|
||||
}
|
||||
|
||||
/// Given a list of rows place them in the layout.
|
||||
/// - Parameters:
|
||||
/// - bounds: The bounds of the parent
|
||||
/// - rows: The input row structure.
|
||||
private func placeSubviews(in bounds: CGRect, rows: [[FlowLayoutSubview]]) {
|
||||
var rowY: CGFloat = bounds.minY
|
||||
var rowHeight: CGFloat = 0
|
||||
for (i, row) in rows.enumerated() {
|
||||
var rowX: CGFloat = bounds.minX
|
||||
let verticalSpacing = i == 0 ? 0 : rowSpacing
|
||||
for (j, subview) in row.enumerated() {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
let horizontalSpacing = j == 0 ? 0 : itemSpacing
|
||||
let point = CGPoint(x: rowX + horizontalSpacing, y: rowY + verticalSpacing + (size.height / 2))
|
||||
subview.place(at: point, anchor: .leading, proposal: ProposedViewSize(size))
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
rowX += size.width + horizontalSpacing
|
||||
}
|
||||
rowY += rowHeight + verticalSpacing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A protocol representing subviews so that we can inject mocks in unit tests.
|
||||
protocol FlowLayoutSubviews: RandomAccessCollection where Element: FlowLayoutSubview, Index == Int, SubSequence == Self { }
|
||||
|
||||
extension LayoutSubviews: FlowLayoutSubviews { }
|
||||
|
||||
/// A protocol representing a subview so that we can inject mocks in unit tests.
|
||||
protocol FlowLayoutSubview {
|
||||
func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize
|
||||
func place(at position: CGPoint, anchor: UnitPoint, proposal: ProposedViewSize)
|
||||
}
|
||||
|
||||
extension LayoutSubview: FlowLayoutSubview { }
|
@ -28,12 +28,13 @@ import SwiftUI
|
||||
/// ```
|
||||
struct ViewFrameReader: View {
|
||||
@Binding var frame: CGRect
|
||||
let coordinateSpace: CoordinateSpace = .local
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
Color.clear
|
||||
.preference(key: FramePreferenceKey.self,
|
||||
value: geometry.frame(in: .local))
|
||||
value: geometry.frame(in: coordinateSpace))
|
||||
}
|
||||
.onPreferenceChange(FramePreferenceKey.self) { newValue in
|
||||
guard frame != newValue else { return }
|
||||
|
@ -127,6 +127,10 @@ struct RoomScreenViewStateBindings {
|
||||
}
|
||||
}
|
||||
|
||||
/// The state of wether reactions listed on the timeline are expanded/collapsed.
|
||||
/// Key is itemID, value is the collapsed state.
|
||||
var reactionsCollapsed: [String: Bool]
|
||||
|
||||
/// A media item that will be previewed with QuickLook.
|
||||
var mediaPreviewItem: MediaPreviewItem?
|
||||
|
||||
|
@ -52,7 +52,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
timelineStyle: appSettings.timelineStyle,
|
||||
readReceiptsEnabled: appSettings.readReceiptsEnabled,
|
||||
isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom,
|
||||
bindings: .init(composerText: "", composerFocused: false)),
|
||||
bindings: .init(composerText: "", composerFocused: false, reactionsCollapsed: [:])),
|
||||
imageProvider: mediaProvider)
|
||||
|
||||
setupSubscriptions()
|
||||
@ -656,6 +656,19 @@ private extension RoomProxyProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
extension RoomScreenViewModel.Context {
|
||||
/// A function to make it easier to bind to reactions expand/collapsed state
|
||||
/// - Parameter itemID: The id of the timeline item the reacted to
|
||||
/// - Returns: Wether the reactions should show in the collapsed state, true by default.
|
||||
func reactionsCollapsedBinding(for itemID: String) -> Binding<Bool> {
|
||||
Binding(get: {
|
||||
self.reactionsCollapsed[itemID] ?? true
|
||||
}, set: {
|
||||
self.reactionsCollapsed[itemID] = $0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mocks
|
||||
|
||||
extension RoomScreenViewModel {
|
||||
|
@ -109,11 +109,9 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
.accessibilityElement(children: .combine)
|
||||
|
||||
if !timelineItem.properties.reactions.isEmpty {
|
||||
TimelineReactionsView(reactions: timelineItem.properties.reactions) { key in
|
||||
context.send(viewAction: .toggleReaction(key: key, eventID: timelineItem.id))
|
||||
} showReactionSummary: { key in
|
||||
context.send(viewAction: .reactionSummary(itemID: timelineItem.id, key: key))
|
||||
}
|
||||
TimelineReactionsView(itemID: timelineItem.id,
|
||||
reactions: timelineItem.properties.reactions,
|
||||
collapsed: context.reactionsCollapsedBinding(for: timelineItem.id))
|
||||
.environment(\.layoutDirection, reactionsLayoutDirection)
|
||||
// Workaround to stop the message long press stealing the touch from the reaction buttons
|
||||
.onTapGesture { }
|
||||
|
@ -117,11 +117,9 @@ struct TimelineItemPlainStylerView<Content: View>: View {
|
||||
}
|
||||
|
||||
if !timelineItem.properties.reactions.isEmpty {
|
||||
TimelineReactionsView(reactions: timelineItem.properties.reactions) { key in
|
||||
context.send(viewAction: .toggleReaction(key: key, eventID: timelineItem.id))
|
||||
} showReactionSummary: { key in
|
||||
context.send(viewAction: .reactionSummary(itemID: timelineItem.id, key: key))
|
||||
}
|
||||
TimelineReactionsView(itemID: timelineItem.id,
|
||||
reactions: timelineItem.properties.reactions,
|
||||
collapsed: context.reactionsCollapsedBinding(for: timelineItem.id))
|
||||
// Workaround to stop the message long press stealing the touch from the reaction buttons
|
||||
.onTapGesture { }
|
||||
}
|
||||
|
@ -14,34 +14,90 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Flow
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineReactionsView: View {
|
||||
@Environment(\.layoutDirection) var layoutDirection: LayoutDirection
|
||||
/// We use a coordinate space for measuring the reactions within their container.
|
||||
/// For some reason when using .local the origin of reactions always shown as (0, 0)
|
||||
private static let flowCoordinateSpace = "flowCoordinateSpace"
|
||||
private static let horizontalSpacing: CGFloat = 4
|
||||
private static let verticalSpacing: CGFloat = 4
|
||||
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
||||
@Environment(\.layoutDirection) private var layoutDirection: LayoutDirection
|
||||
|
||||
let itemID: String
|
||||
let reactions: [AggregatedReaction]
|
||||
let toggleReaction: (String) -> Void
|
||||
let showReactionSummary: (String) -> Void
|
||||
@Binding var collapsed: Bool
|
||||
|
||||
var body: some View {
|
||||
HFlow(itemSpacing: 4, rowSpacing: 4) {
|
||||
CollapsibleFlowLayout(itemSpacing: 4, rowSpacing: 4, collapsed: collapsed, rowsBeforeCollapsible: 2) {
|
||||
ForEach(reactions, id: \.self) { reaction in
|
||||
TimelineReactionButton(reaction: reaction,
|
||||
toggleReaction: toggleReaction,
|
||||
showReactionSummary: showReactionSummary)
|
||||
TimelineReactionButton(itemID: itemID, reaction: reaction) { key in
|
||||
context.send(viewAction: .toggleReaction(key: key, eventID: itemID))
|
||||
} showReactionSummary: { key in
|
||||
context.send(viewAction: .reactionSummary(itemID: itemID, key: key))
|
||||
}
|
||||
}
|
||||
.environment(\.layoutDirection, layoutDirection)
|
||||
Button {
|
||||
collapsed.toggle()
|
||||
} label: {
|
||||
TimelineCollapseButtonLabel(collapsed: collapsed)
|
||||
}
|
||||
}
|
||||
.coordinateSpace(name: Self.flowCoordinateSpace)
|
||||
}
|
||||
}
|
||||
|
||||
/// The pill shape for the label that surrounds both the reaction and collapse buttons.
|
||||
struct TimelineReactionButtonLabel<Content: View>: View {
|
||||
var isHighlighted = false
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
content()
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
.background(backgroundShape.inset(by: 1).fill(overlayBackgroundColor))
|
||||
.overlay(backgroundShape.inset(by: 2.0).strokeBorder(overlayBorderColor))
|
||||
.overlay(backgroundShape.strokeBorder(Color.compound.bgCanvasDefault, lineWidth: 2))
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
var backgroundShape: some InsettableShape {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
}
|
||||
|
||||
var overlayBackgroundColor: Color {
|
||||
isHighlighted ? Color.compound.bgSubtlePrimary : .compound.bgSubtleSecondary
|
||||
}
|
||||
|
||||
var overlayBorderColor: Color {
|
||||
isHighlighted ? Color.compound.borderInteractivePrimary : .clear
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineCollapseButtonLabel: View {
|
||||
var collapsed: Bool
|
||||
|
||||
var body: some View {
|
||||
TimelineReactionButtonLabel {
|
||||
Text(collapsed ? L10n.screenRoomReactionsShowMore : L10n.screenRoomReactionsShowLess)
|
||||
.layoutPriority(1)
|
||||
.drawingGroup()
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineReactionButton: View {
|
||||
let itemID: String
|
||||
let reaction: AggregatedReaction
|
||||
let toggleReaction: (String) -> Void
|
||||
let showReactionSummary: (String) -> Void
|
||||
|
||||
@State private var didLongPress = false
|
||||
|
||||
var body: some View {
|
||||
label
|
||||
.onTapGesture {
|
||||
@ -53,7 +109,7 @@ struct TimelineReactionButton: View {
|
||||
}
|
||||
|
||||
var label: some View {
|
||||
HStack(spacing: 4) {
|
||||
TimelineReactionButtonLabel(isHighlighted: reaction.isHighlighted) {
|
||||
Text(reaction.key)
|
||||
.font(.compound.bodyMD)
|
||||
if reaction.count > 1 {
|
||||
@ -62,41 +118,33 @@ struct TimelineReactionButton: View {
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
.background(backgroundShape.inset(by: 1).fill(overlayBackgroundColor))
|
||||
.overlay(backgroundShape.inset(by: 2.0).strokeBorder(overlayBorderColor))
|
||||
.overlay(backgroundShape.strokeBorder(Color.compound.bgCanvasDefault, lineWidth: 2))
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
var backgroundShape: some InsettableShape {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
}
|
||||
|
||||
var textColor: Color {
|
||||
reaction.isHighlighted ? Color.compound.textPrimary : .compound.textSecondary
|
||||
}
|
||||
|
||||
var overlayBackgroundColor: Color {
|
||||
reaction.isHighlighted ? Color.compound.bgSubtlePrimary : .compound.bgSubtleSecondary
|
||||
}
|
||||
|
||||
var overlayBorderColor: Color {
|
||||
reaction.isHighlighted ? Color.compound.borderInteractivePrimary : .clear
|
||||
struct TimelineReactionViewPreviewsContainer: View {
|
||||
@State private var collapseState1 = false
|
||||
@State private var collapseState2 = true
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
TimelineReactionsView(itemID: "1", reactions: Array(AggregatedReaction.mockReactions.prefix(3)), collapsed: .constant(true))
|
||||
Divider()
|
||||
TimelineReactionsView(itemID: "2", reactions: AggregatedReaction.mockReactions, collapsed: $collapseState1)
|
||||
Divider()
|
||||
TimelineReactionsView(itemID: "3", reactions: AggregatedReaction.mockReactions, collapsed: $collapseState2)
|
||||
.environment(\.layoutDirection, .rightToLeft)
|
||||
}
|
||||
.background(Color.red)
|
||||
.frame(maxWidth: 250, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineReactionView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
TimelineReactionButton(reaction: AggregatedReaction.mockThumbsUpHighlighted) { _ in } showReactionSummary: { _ in }
|
||||
TimelineReactionButton(reaction: AggregatedReaction.mockClap) { _ in } showReactionSummary: { _ in }
|
||||
TimelineReactionButton(reaction: AggregatedReaction.mockParty) { _ in } showReactionSummary: { _ in }
|
||||
TimelineReactionsView(reactions: AggregatedReaction.mockReactions) { _ in } showReactionSummary: { _ in }
|
||||
.environment(\.layoutDirection, .leftToRight)
|
||||
TimelineReactionsView(reactions: AggregatedReaction.mockReactions) { _ in } showReactionSummary: { _ in }
|
||||
.environment(\.layoutDirection, .rightToLeft)
|
||||
}
|
||||
TimelineReactionViewPreviewsContainer()
|
||||
}
|
||||
}
|
||||
|
@ -66,17 +66,7 @@ enum RoomTimelineItemFixtures {
|
||||
isEditable: true,
|
||||
sender: .init(id: "", displayName: "Bob"),
|
||||
content: .init(body: "New home office set up!"),
|
||||
properties: RoomTimelineItemProperties(reactions: [
|
||||
AggregatedReaction(accountOwnerID: "me", key: "🙏", senders: ["helena"]),
|
||||
AggregatedReaction(accountOwnerID: "me", key: "🙌", senders: ["me", "helena", "jacob", "bob", "alice", "charlie", "dan", "erin"]),
|
||||
AggregatedReaction(accountOwnerID: "me", key: "👌", senders: ["helena"]),
|
||||
AggregatedReaction(accountOwnerID: "me", key: "🥳", senders: ["bob"]),
|
||||
AggregatedReaction(accountOwnerID: "me", key: "🍾", senders: ["charlie"]),
|
||||
AggregatedReaction(accountOwnerID: "me", key: "🎈", senders: ["helena"]),
|
||||
AggregatedReaction(accountOwnerID: "me", key: "👏", senders: ["helena"]),
|
||||
AggregatedReaction(accountOwnerID: "me", key: "🎉", senders: ["helena"])
|
||||
|
||||
],
|
||||
properties: RoomTimelineItemProperties(reactions: AggregatedReaction.mockReactions,
|
||||
orderedReadReceipts: [ReadReceipt(userID: "alice", formattedTimestamp: nil),
|
||||
ReadReceipt(userID: "bob", formattedTimestamp: nil),
|
||||
ReadReceipt(userID: "charlie", formattedTimestamp: nil),
|
||||
|
@ -323,8 +323,12 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
private func aggregateReactions(_ reactions: [Reaction]) -> [AggregatedReaction] {
|
||||
reactions.map { reaction in
|
||||
AggregatedReaction(accountOwnerID: userID, key: reaction.key, senders: reaction.senders)
|
||||
}.sorted { a, b in
|
||||
a.count > b.count
|
||||
}
|
||||
.sorted { a, b in
|
||||
// Sort by count and then by key for a consistence experience.
|
||||
// Otherwise emojis can switch around. We can replace
|
||||
// with timestamp as a secondary sort when it is available.
|
||||
(a.count, a.key) > (b.count, b.key)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -156,7 +156,6 @@ targets:
|
||||
- package: Sentry
|
||||
- package: URLRouting
|
||||
- package: Version
|
||||
- package: Flow
|
||||
|
||||
sources:
|
||||
- path: ../Sources
|
||||
|
@ -161,12 +161,6 @@ class RoomScreenUITests: XCTestCase {
|
||||
try await app.assertScreenshot(.roomSmallTimelineWithReadReceipts)
|
||||
}
|
||||
|
||||
func testTimelineReactions() async throws {
|
||||
let app = Application.launch(.roomSmallTimelineWithReactions)
|
||||
|
||||
try await app.assertScreenshot(.roomSmallTimelineWithReactions)
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func performOperation(_ operation: UITestsSignal, using client: UITestsSignalling.Client) async throws {
|
||||
|
BIN
UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomEncryptedWithAvatar.png
(Stored with Git LFS)
BIN
UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomEncryptedWithAvatar.png
(Stored with Git LFS)
Binary file not shown.
BIN
UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomPlainNoAvatar.png
(Stored with Git LFS)
BIN
UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomPlainNoAvatar.png
(Stored with Git LFS)
Binary file not shown.
BIN
UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomSmallTimelineWithReactions.png
(Stored with Git LFS)
BIN
UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomSmallTimelineWithReactions.png
(Stored with Git LFS)
Binary file not shown.
BIN
UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomEncryptedWithAvatar.png
(Stored with Git LFS)
BIN
UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomEncryptedWithAvatar.png
(Stored with Git LFS)
Binary file not shown.
BIN
UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomPlainNoAvatar.png
(Stored with Git LFS)
BIN
UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomPlainNoAvatar.png
(Stored with Git LFS)
Binary file not shown.
BIN
UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomSmallTimelineWithReactions.png
(Stored with Git LFS)
BIN
UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomSmallTimelineWithReactions.png
(Stored with Git LFS)
Binary file not shown.
BIN
UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomEncryptedWithAvatar.png
(Stored with Git LFS)
BIN
UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomEncryptedWithAvatar.png
(Stored with Git LFS)
Binary file not shown.
BIN
UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomPlainNoAvatar.png
(Stored with Git LFS)
BIN
UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomPlainNoAvatar.png
(Stored with Git LFS)
Binary file not shown.
BIN
UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomSmallTimelineWithReactions.png
(Stored with Git LFS)
BIN
UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomSmallTimelineWithReactions.png
(Stored with Git LFS)
Binary file not shown.
BIN
UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomEncryptedWithAvatar.png
(Stored with Git LFS)
BIN
UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomEncryptedWithAvatar.png
(Stored with Git LFS)
Binary file not shown.
BIN
UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomPlainNoAvatar.png
(Stored with Git LFS)
BIN
UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomPlainNoAvatar.png
(Stored with Git LFS)
Binary file not shown.
BIN
UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomSmallTimelineWithReactions.png
(Stored with Git LFS)
BIN
UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomSmallTimelineWithReactions.png
(Stored with Git LFS)
Binary file not shown.
137
UnitTests/Sources/LayoutTests/CollapsibleFlowLayoutTests.swift
Normal file
137
UnitTests/Sources/LayoutTests/CollapsibleFlowLayoutTests.swift
Normal file
@ -0,0 +1,137 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2 (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
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
@testable import ElementX
|
||||
import SwiftUI
|
||||
import XCTest
|
||||
|
||||
final class CollapsibleFlowLayoutTests: XCTestCase {
|
||||
func testFlowLayoutWithExpandAndCollapse() {
|
||||
let containerSize = CGSize(width: 250, height: 400)
|
||||
var flowLayout = CollapsibleFlowLayout(itemSpacing: 5, rowSpacing: 5, rowsBeforeCollapsible: 2)
|
||||
|
||||
var placedViews: [CGRect] = []
|
||||
let placedViewsCallback = { rect in
|
||||
placedViews.append(rect)
|
||||
}
|
||||
let subviews: [LayoutSubviewMock] = [
|
||||
LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback),
|
||||
LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback),
|
||||
LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback),
|
||||
LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback),
|
||||
LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback),
|
||||
LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback),
|
||||
// The expand/collapse button
|
||||
LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback)
|
||||
]
|
||||
let subviewsMock = LayoutSubviewsMock(subviews: subviews)
|
||||
var a: () = ()
|
||||
var size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
|
||||
|
||||
// Collapsed target layout has 2 rows of 2 items, so just 1 spacing between items hence 205, 105
|
||||
XCTAssertEqual(size, CGSize(width: 205, height: 105))
|
||||
flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
|
||||
|
||||
// 3 items are hidden in the collapsed state (put in the centre with zero size)
|
||||
var targetPlacements: [CGRect] = [
|
||||
CGRect(x: 0, y: 25, width: 100, height: 50),
|
||||
CGRect(x: 105, y: 25, width: 100, height: 50),
|
||||
CGRect(x: 0, y: 80, width: 100, height: 50),
|
||||
CGRect(x: 105, y: 80, width: 100, height: 50),
|
||||
CGRect(x: -10000, y: -10000, width: 0, height: 0),
|
||||
CGRect(x: -10000, y: -10000, width: 0, height: 0),
|
||||
CGRect(x: -10000, y: -10000, width: 0, height: 0)
|
||||
]
|
||||
XCTAssertEqual(placedViews, targetPlacements)
|
||||
|
||||
flowLayout.collapsed = false
|
||||
placedViews = []
|
||||
|
||||
size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
|
||||
|
||||
// Expanded target layout has 4 rows and no more than 2 items per row
|
||||
XCTAssertEqual(size, CGSize(width: 205, height: 215))
|
||||
|
||||
flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
|
||||
|
||||
targetPlacements = [
|
||||
CGRect(x: 0, y: 25, width: 100, height: 50),
|
||||
CGRect(x: 105, y: 25, width: 100, height: 50),
|
||||
CGRect(x: 0, y: 80, width: 100, height: 50),
|
||||
CGRect(x: 105, y: 80, width: 100, height: 50),
|
||||
CGRect(x: 0, y: 135, width: 100, height: 50),
|
||||
CGRect(x: 105, y: 135, width: 100, height: 50),
|
||||
CGRect(x: 0, y: 190, width: 100, height: 50)
|
||||
]
|
||||
XCTAssertEqual(placedViews, targetPlacements)
|
||||
}
|
||||
|
||||
func testFlowLayoutWithExpandButtonIsHidden() {
|
||||
let containerSize = CGSize(width: 250, height: 400)
|
||||
let flowLayout = CollapsibleFlowLayout(itemSpacing: 5, rowSpacing: 5, rowsBeforeCollapsible: 2)
|
||||
|
||||
var placedViews: [CGRect] = []
|
||||
let placedViewsCallback = { rect in
|
||||
placedViews.append(rect)
|
||||
}
|
||||
let subviews: [LayoutSubviewMock] = [
|
||||
LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback),
|
||||
LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback),
|
||||
LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback),
|
||||
LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback)
|
||||
]
|
||||
let subviewsMock = LayoutSubviewsMock(subviews: subviews)
|
||||
var a: () = ()
|
||||
let size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
|
||||
|
||||
XCTAssertEqual(size, CGSize(width: 205, height: 105))
|
||||
flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
|
||||
|
||||
let targetPlacements: [CGRect] = [
|
||||
CGRect(x: 0, y: 25, width: 100, height: 50),
|
||||
CGRect(x: 105, y: 25, width: 100, height: 50),
|
||||
CGRect(x: 0, y: 80, width: 100, height: 50),
|
||||
// Button is hidden
|
||||
CGRect(x: -10000, y: -10000, width: 0, height: 0)
|
||||
]
|
||||
XCTAssertEqual(placedViews, targetPlacements)
|
||||
}
|
||||
|
||||
func testFlowLayoutEmptyState() {
|
||||
let containerSize = CGSize(width: 250, height: 400)
|
||||
let flowLayout = CollapsibleFlowLayout(itemSpacing: 5, rowSpacing: 5, rowsBeforeCollapsible: 2)
|
||||
|
||||
var placedViews: [CGRect] = []
|
||||
let placedViewsCallback = { rect in
|
||||
placedViews.append(rect)
|
||||
}
|
||||
let subviews: [LayoutSubviewMock] = [
|
||||
// No subviews to layout just the expand/collapse button
|
||||
LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback)
|
||||
]
|
||||
let subviewsMock = LayoutSubviewsMock(subviews: subviews)
|
||||
var a: () = ()
|
||||
let size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
|
||||
|
||||
XCTAssertEqual(size, CGSize(width: 0, height: 0))
|
||||
flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a)
|
||||
|
||||
let targetPlacements: [CGRect] = [
|
||||
CGRect(x: -10000, y: -10000, width: 0, height: 0)
|
||||
]
|
||||
XCTAssertEqual(placedViews, targetPlacements)
|
||||
}
|
||||
}
|
80
UnitTests/Sources/LayoutTests/LayoutMocks.swift
Normal file
80
UnitTests/Sources/LayoutTests/LayoutMocks.swift
Normal file
@ -0,0 +1,80 @@
|
||||
//
|
||||
// Copyright 2023 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.
|
||||
//
|
||||
|
||||
@testable import ElementX
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// A mock of the SwiftUI `LayoutSubviews` struct
|
||||
struct LayoutSubviewsMock: Equatable, RandomAccessCollection {
|
||||
var subviews: [LayoutSubviewMock]
|
||||
|
||||
/// A type that contains a subsequence of proxy values.
|
||||
typealias SubSequence = LayoutSubviewsMock
|
||||
|
||||
/// A type that contains a proxy value.
|
||||
typealias Element = LayoutSubviewMock
|
||||
|
||||
/// A type that you can use to index proxy values.
|
||||
typealias Index = Int
|
||||
|
||||
/// The index of the first subview.
|
||||
var startIndex: Int {
|
||||
subviews.startIndex
|
||||
}
|
||||
|
||||
/// An index that's one higher than the last subview.
|
||||
var endIndex: Int {
|
||||
subviews.endIndex
|
||||
}
|
||||
|
||||
/// Gets the subview proxy at a specified index.
|
||||
subscript(index: Int) -> LayoutSubviewsMock.Element {
|
||||
subviews[index]
|
||||
}
|
||||
|
||||
/// Gets the subview proxies in the specified range.
|
||||
subscript(bounds: Range<Int>) -> LayoutSubviewsMock {
|
||||
LayoutSubviewsMock(subviews: Array(subviews[bounds]))
|
||||
}
|
||||
|
||||
/// Gets the subview proxies with the specified indicies.
|
||||
subscript<S>(indices: S) -> LayoutSubviewsMock where S: Sequence, S.Element == Int {
|
||||
LayoutSubviewsMock(subviews: Array(indices.map { subviews[$0] }))
|
||||
}
|
||||
}
|
||||
|
||||
/// A mock of the SwiftUI `LayoutSubview` struct
|
||||
struct LayoutSubviewMock: FlowLayoutSubview, Equatable {
|
||||
var size: CGSize
|
||||
|
||||
var placedPositionCallback: (CGRect) -> Void
|
||||
|
||||
func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize {
|
||||
size
|
||||
}
|
||||
|
||||
func place(at position: CGPoint, anchor: UnitPoint, proposal: ProposedViewSize) {
|
||||
let rect = CGRect(origin: position, size: CGSize(width: proposal.width ?? 0, height: proposal.height ?? 0))
|
||||
placedPositionCallback(rect)
|
||||
}
|
||||
|
||||
static func == (lhs: LayoutSubviewMock, rhs: LayoutSubviewMock) -> Bool {
|
||||
lhs.size == rhs.size
|
||||
}
|
||||
}
|
||||
|
||||
extension LayoutSubviewsMock: FlowLayoutSubviews { }
|
@ -103,6 +103,3 @@ packages:
|
||||
Version:
|
||||
url: https://github.com/mxcl/Version
|
||||
minorVersion: 2.0.0
|
||||
Flow:
|
||||
url: https://github.com/tevelee/SwiftUI-Flow
|
||||
minorVersion: 1.2.0
|
||||
|
Loading…
x
Reference in New Issue
Block a user