Create & Join rooms UI/UX tweaks (#1005)

* Fix people icon size

* Disable dismiss when loading in StartChatScreen

* Reduce debounce on search

* Add padding to invite button

* Add show/hide bottom bar behaviour

* Fix bg color in StartChatScreen form

* Update localisations

* Update search users results to 10

* Fix form background in InviteUsersScreen

* Fix invites list bg color

* Add mxid in InvitesScreenCell

* Refactor InvitesScreenCell design

* Fix separator spacing

* Add more design fixes

* Refine InvitesScreenCell layout

* Refactor UserProfileCell design

* Refactor FormRowAccessory

* Update localisations

* Fix camera size and security section padding

* Change create button style

* Refine InviteUsersScreenSelectedItem

* Refine CreateRoomScreen

* Refine UserProfileCell padding

* Amend preview name

* delete test code

* Add separator in security section

* Fix spacing in InviteUsersScreenSelectedItem

* Revert confirmationAction in CreateRoomScreen

* Refine HomeScreenInvitesButton

* Add dismiss keyboard in InviteUsersScreen

* Refactor FormRowAccessory

* Remove isDisabled from FormButtonStyles

* Update localisations

* Cleanup RoomDetailsEditScreen

* UI fixes on horizontal user carousel

* Refactor InviteUsersScreenSelectedItem

* Try fix flaky tests

* Fix typo

* Update snapshot tests

* Fix flaky UTs

* Fix HomeScreen ui tests

* Fix scroll on selection

* Refactor RoomFlowCoordinatorTests
This commit is contained in:
Alfonso Grillo
2023-06-06 10:03:38 +02:00
committed by GitHub
parent fedd401a56
commit 4e926c3334
87 changed files with 484 additions and 315 deletions
@@ -26,32 +26,25 @@ struct CreateRoomScreen: View {
}
var body: some View {
mainContent
.scrollDismissesKeyboard(.immediately)
.elementFormStyle()
.navigationTitle(L10n.screenCreateRoomTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
createButton
}
}
.background(ViewFrameReader(frame: $frame))
.alert(item: $context.alertInfo) { $0.alert }
}
/// The main content of the view to be shown in a scroll view.
var mainContent: some View {
Form {
roomSection
topicSection
Spacer()
.listRowBackground(Color.clear)
securitySection
}
.elementFormStyle()
.scrollDismissesKeyboard(.immediately)
.navigationTitle(L10n.screenCreateRoomTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
createButton
}
}
.background(ViewFrameReader(frame: $frame))
.alert(item: $context.alertInfo) { $0.alert }
}
@ScaledMetric private var roomIconSize: CGFloat = 64
@ScaledMetric private var roomIconSize: CGFloat = 70
private var roomSection: some View {
Section {
HStack(alignment: .center, spacing: 16) {
@@ -94,7 +87,7 @@ struct CreateRoomScreen: View {
.formSectionHeader()
TextField(L10n.screenCreateRoomRoomNameLabel,
text: $context.roomName,
prompt: Text(L10n.screenCreateRoomRoomNamePlaceholder),
prompt: Text(L10n.commonRoomNamePlaceholder),
axis: .horizontal)
.focused($focus, equals: .name)
.accessibilityIdentifier(A11yIdentifiers.createRoomScreen.roomName)
@@ -111,6 +104,7 @@ struct CreateRoomScreen: View {
private var cameraImage: some View {
Image(systemName: "camera")
.font(.system(size: 28, weight: .semibold))
.foregroundColor(.element.secondaryContent)
.frame(width: roomIconSize, height: roomIconSize)
.background(Color.element.quinaryContent)
@@ -121,7 +115,7 @@ struct CreateRoomScreen: View {
Section {
TextField(L10n.screenCreateRoomTopicLabel,
text: $context.roomTopic,
prompt: Text(L10n.screenCreateRoomTopicPlaceholder),
prompt: Text(L10n.commonTopicPlaceholder),
axis: .vertical)
.focused($focus, equals: .topic)
.accessibilityIdentifier(A11yIdentifiers.createRoomScreen.roomTopic)
@@ -138,11 +132,11 @@ struct CreateRoomScreen: View {
}
@State private var frame: CGRect = .zero
@ScaledMetric private var invitedUserCellWidth: CGFloat = 64
@ScaledMetric private var invitedUserCellWidth: CGFloat = 72
private var selectedUsersSection: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 28) {
LazyHStack(spacing: 16) {
ForEach(context.viewState.selectedUsers, id: \.userID) { user in
InviteUsersScreenSelectedItem(user: user, imageProvider: context.imageProvider) {
context.send(viewAction: .deselectUser(user))
@@ -150,7 +144,7 @@ struct CreateRoomScreen: View {
.frame(width: invitedUserCellWidth)
}
}
.padding(.init(top: 16, leading: 32, bottom: 16, trailing: 32))
.padding(.init(top: 22, leading: 20, bottom: 0, trailing: 32))
}
.frame(width: frame.width)
}
@@ -159,23 +153,26 @@ struct CreateRoomScreen: View {
Section {
Picker(L10n.commonSecurity, selection: $context.isRoomPrivate) {
Label {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.screenCreateRoomPrivateOptionTitle)
.font(.compound.bodyLG)
Text(L10n.screenCreateRoomPrivateOptionDescription)
.font(.compound.bodyMD)
.foregroundColor(.element.secondaryContent)
.font(.compound.bodyXS)
.foregroundColor(.element.tertiaryContent)
}
} icon: {
Image(systemName: "lock.shield")
}
.tag(true)
.labelStyle(FormRowLabelStyle(alignment: .top))
Label {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.screenCreateRoomPublicOptionTitle)
.font(.compound.bodyLG)
Text(L10n.screenCreateRoomPublicOptionDescription)
.font(.compound.bodyMD)
.foregroundColor(.element.secondaryContent)
.font(.compound.bodyXS)
.foregroundColor(.element.tertiaryContent)
}
} icon: {
Image(systemName: "exclamationmark.shield")
@@ -188,8 +185,10 @@ struct CreateRoomScreen: View {
} header: {
Text(L10n.commonSecurity.uppercased())
.formSectionHeader()
.padding(.top, 40)
}
.formSectionStyle()
.listRowSeparatorTint(.element.quinaryContent)
.listRowBackground(Color.element.formRowBackground)
}
private var createButton: some View {
@@ -26,6 +26,7 @@ struct HomeScreen: View {
@State private var scrollViewAdapter = ScrollViewAdapter()
@State private var showingLogoutConfirmation = false
@State private var showingBottomToolbar = true
var body: some View {
ScrollView {
@@ -99,6 +100,11 @@ struct HomeScreen: View {
}
}
}
.onReceive(scrollViewAdapter.scrollDirection) { direction in
withAnimation(.elementDefault) {
showingBottomToolbar = (direction == .down)
}
}
.onChange(of: context.viewState.visibleRooms) { _ in
// Give the view a chance to update
DispatchQueue.main.async {
@@ -115,20 +121,27 @@ struct HomeScreen: View {
actions: leaveRoomAlertActions,
message: leaveRoomAlertMessage)
.navigationTitle(L10n.screenRoomlistMainSpaceTitle)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
userMenuButton
}
if context.viewState.startChatFlowEnabled {
ToolbarItemGroup(placement: .bottomBar) {
Spacer()
newRoomButton
}
}
}
.toolbar(showingBottomToolbar ? .automatic : .hidden, for: .bottomBar)
.toolbar { toolbar }
.background(Color.element.background.ignoresSafeArea())
.track(screen: .home)
}
// MARK: - Private
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
userMenuButton
}
ToolbarItemGroup(placement: .bottomBar) {
if context.viewState.startChatFlowEnabled {
Spacer()
newRoomButton
}
}
}
@ViewBuilder
private var userMenuButton: some View {
@@ -34,6 +34,7 @@ struct HomeScreenInvitesButton: View {
badge
}
}
.padding(.vertical, 4)
}
}
@@ -46,7 +47,7 @@ struct HomeScreenInvitesButton: View {
}
}
struct InvitesButton_Previews: PreviewProvider {
struct HomeScreenInvitesButton_Previews: PreviewProvider {
static var previews: some View {
HomeScreenInvitesButton(title: "Invites", hasBadge: true, action: { })
.previewDisplayName("Badge on")
@@ -55,6 +55,8 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
actionsSubject.send(.invite(users: state.selectedUsers.map(\.userID)))
}
case .toggleUser(let user):
let willSelectUser = !state.selectedUsers.contains(user)
state.scrollToLastID = willSelectUser ? user.userID : nil
actionsSubject.send(.toggleUser(user))
}
}
@@ -23,6 +23,7 @@ struct InviteUsersScreen: View {
var body: some View {
mainContent
.elementFormStyle()
.scrollDismissesKeyboard(.immediately)
.navigationTitle(L10n.screenCreateRoomActionInvitePeople)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -69,33 +70,38 @@ struct InviteUsersScreen: View {
.accessibilityIdentifier(A11yIdentifiers.startChatScreen.searchNoResults)
}
@ViewBuilder
private var usersSection: some View {
Section {
ForEach(context.viewState.usersSection.users, id: \.userID) { user in
Button { context.send(viewAction: .toggleUser(user)) } label: {
UserProfileCell(user: user,
membership: context.viewState.membershipState(user),
imageProvider: context.imageProvider)
if !context.viewState.usersSection.users.isEmpty {
Section {
ForEach(context.viewState.usersSection.users, id: \.userID) { user in
Button { context.send(viewAction: .toggleUser(user)) } label: {
UserProfileCell(user: user,
membership: context.viewState.membershipState(user),
imageProvider: context.imageProvider)
}
.disabled(context.viewState.isUserDisabled(user))
.buttonStyle(FormButtonStyle(accessory: .selection(isSelected: context.viewState.isUserSelected(user))))
}
} header: {
if let title = context.viewState.usersSection.title {
Text(title)
}
.buttonStyle(FormButtonStyle(isDisabled: context.viewState.isUserDisabled(user),
accessory: .selection(isSelected: context.viewState.isUserSelected(user))))
}
} header: {
if let title = context.viewState.usersSection.title {
Text(title)
}
.listRowSeparator(.automatic)
.formSectionStyle()
} else {
Section.empty
}
.listRowSeparator(.automatic)
.formSectionStyle()
}
@State private var frame: CGRect = .zero
@ScaledMetric private var cellWidth: CGFloat = 64
@ScaledMetric private var cellWidth: CGFloat = 72
private var selectedUsersSection: some View {
ScrollView(.horizontal, showsIndicators: false) {
ScrollViewReader { scrollView in
HStack(spacing: 28) {
HStack(spacing: 16) {
ForEach(context.viewState.selectedUsers, id: \.userID) { user in
InviteUsersScreenSelectedItem(user: user, imageProvider: context.imageProvider) {
deselect(user)
@@ -109,7 +115,7 @@ struct InviteUsersScreen: View {
scrollView.scrollTo(id)
}
}
.padding(.horizontal, 18)
.padding(.horizontal, 14)
}
}
.frame(width: frame.width)
@@ -21,30 +21,50 @@ struct InviteUsersScreenSelectedItem: View {
let imageProvider: ImageProviderProtocol?
let dismissAction: () -> Void
@ScaledMetric private var buttonSize: CGFloat = 20
var body: some View {
VStack(spacing: 4) {
LoadableAvatarImage(url: user.avatarURL,
name: user.displayName,
contentID: user.userID,
avatarSize: .user(on: .inviteUsers),
imageProvider: imageProvider)
VStack(spacing: 0) {
avatar
Text(user.displayName ?? user.userID)
.font(.compound.headingSM)
.font(.compound.bodyMD)
.foregroundColor(.element.primaryContent)
.lineLimit(1)
}
.overlay(alignment: .topTrailing) {
Button(action: dismissAction) {
Image(systemName: "xmark.circle.fill")
.symbolRenderingMode(.palette)
.foregroundStyle(Color.element.systemPrimaryBackground, Color.element.primaryContent)
}
// MARK: - Private
var avatar: some View {
LoadableAvatarImage(url: user.avatarURL,
name: user.displayName,
contentID: user.userID,
avatarSize: .user(on: .inviteUsers),
imageProvider: imageProvider)
.overlay(alignment: .topTrailing) {
Button(action: dismissAction) {
Image(systemName: "xmark.circle.fill")
.resizable()
.frame(width: buttonSize, height: buttonSize)
.symbolRenderingMode(.palette)
.foregroundStyle(Color.element.systemPrimaryBackground, Color.element.primaryContent)
}
}
}
}
}
struct InviteUsersScreenSelectedItem_Previews: PreviewProvider {
static let people: [UserProfileProxy] = [.mockAlice, .mockVerbose]
static var previews: some View {
InviteUsersScreenSelectedItem(user: .mockAlice, imageProvider: MockMediaProvider(), dismissAction: { })
ScrollView(.horizontal) {
HStack(spacing: 28) {
ForEach(Self.people, id: \.userID) { user in
InviteUsersScreenSelectedItem(user: user, imageProvider: MockMediaProvider(), dismissAction: { })
.frame(width: 72)
}
}
}
}
}
@@ -22,7 +22,7 @@ struct InvitesScreen: View {
var body: some View {
ScrollView {
if let rooms = context.viewState.invites, !rooms.isEmpty {
LazyVStack {
LazyVStack(spacing: 0) {
ForEach(rooms, id: \.roomDetails.id) { invite in
InvitesScreenCell(invite: invite,
imageProvider: context.imageProvider,
@@ -34,6 +34,7 @@ struct InvitesScreen: View {
noInvitesContent
}
}
.background(Color.element.background.ignoresSafeArea())
.navigationTitle(L10n.actionInvitesList)
.alert(item: $context.alertInfo) { $0.alert }
}
@@ -23,7 +23,6 @@ struct InvitesScreenCell: View {
let acceptAction: () -> Void
let declineAction: () -> Void
private let verticalInsets = 16.0
@ScaledMetric private var badgeSize = 12.0
var body: some View {
@@ -37,21 +36,23 @@ struct InvitesScreenCell: View {
mainContent
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, verticalInsets)
.padding(.bottom, 16)
.padding(.trailing, 16)
.overlay(alignment: .bottom) {
separator
}
}
.padding(.top, verticalInsets)
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.leading, 16)
}
// MARK: - Private
private var mainContent: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 12) {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 16) {
textualContent
.padding(.trailing, invite.isUnread ? 0 : 16)
if invite.isUnread {
badge
@@ -59,26 +60,28 @@ struct InvitesScreenCell: View {
}
inviterView
.padding(.top, 6)
.padding(.trailing, 16)
buttons
.padding(.trailing, 26)
.padding(.top, 8)
.padding(.top, 14)
.padding(.trailing, 22)
}
}
@ViewBuilder
private var inviterView: some View {
if let invitedText = attributedInviteText, let name = invite.inviter?.displayName {
HStack {
HStack(alignment: .firstTextBaseline) {
LoadableAvatarImage(url: invite.inviter?.avatarURL,
name: name,
contentID: name,
avatarSize: .custom(16),
imageProvider: imageProvider)
.alignmentGuide(.firstTextBaseline) { $0[.bottom] * 0.8 }
Text(invitedText)
}
.padding(.top, 4)
}
}
@@ -88,6 +91,7 @@ struct InvitesScreenCell: View {
Text(title)
.font(.compound.bodyLGSemibold)
.foregroundColor(.element.primaryContent)
.lineLimit(2)
if let subtitle {
Text(subtitle)
@@ -130,16 +134,21 @@ struct InvitesScreenCell: View {
}
private var attributedInviteText: AttributedString? {
guard invite.roomDetails.isDirect == false, let inviterName = invite.inviter?.displayName else {
guard
invite.roomDetails.isDirect == false,
let inviterName = invite.inviter?.displayName,
let inviterID = invite.inviter?.userID
else {
return nil
}
let text = L10n.screenInvitesInvitedYou(inviterName)
let text = L10n.screenInvitesInvitedYou(inviterName, inviterID)
var attributedString = AttributedString(text)
attributedString.font = .compound.bodyMD
attributedString.foregroundColor = .compound.textPlaceholder
if let range = attributedString.range(of: inviterName) {
attributedString[range].foregroundColor = .compound.textPrimary
attributedString[range].font = .compound.bodyMDSemibold
}
return attributedString
}
@@ -153,18 +162,21 @@ struct InvitesScreenCell: View {
struct InvitesScreenCell_Previews: PreviewProvider {
static var previews: some View {
InvitesScreenCell(invite: .dm, imageProvider: MockMediaProvider(), acceptAction: { }, declineAction: { })
.previewDisplayName("Direct room")
InvitesScreenCell(invite: .room(alias: nil), imageProvider: MockMediaProvider(), acceptAction: { }, declineAction: { })
.previewDisplayName("Default room")
InvitesScreenCell(invite: .room(alias: "#footest:somewhere.org"), imageProvider: MockMediaProvider(), acceptAction: { }, declineAction: { })
.previewDisplayName("Aliased room")
InvitesScreenCell(invite: .room(alias: "#footest:somewhere.org"), imageProvider: MockMediaProvider(), acceptAction: { }, declineAction: { })
.dynamicTypeSize(.accessibility1)
.previewDisplayName("Aliased room (AX1)")
ScrollView {
VStack(spacing: 0) {
InvitesScreenCell(invite: .dm, imageProvider: MockMediaProvider(), acceptAction: { }, declineAction: { })
InvitesScreenCell(invite: .room(), imageProvider: MockMediaProvider(), acceptAction: { }, declineAction: { })
InvitesScreenCell(invite: .room(isUnread: false), imageProvider: MockMediaProvider(), acceptAction: { }, declineAction: { })
InvitesScreenCell(invite: .room(alias: "#footest:somewhere.org", avatarURL: .picturesDirectory), imageProvider: MockMediaProvider(), acceptAction: { }, declineAction: { })
InvitesScreenCell(invite: .room(alias: "#footest:somewhere.org"), imageProvider: MockMediaProvider(), acceptAction: { }, declineAction: { })
.dynamicTypeSize(.accessibility1)
.previewDisplayName("Aliased room (AX1)")
}
}
}
}
@@ -186,20 +198,20 @@ private extension InvitesScreenRoomDetails {
return .init(roomDetails: dmRoom, inviter: inviter, isUnread: false)
}
static func room(alias: String?) -> InvitesScreenRoomDetails {
static func room(alias: String? = nil, avatarURL: URL? = nil, isUnread: Bool = true) -> InvitesScreenRoomDetails {
let dmRoom = RoomSummaryDetails(id: "@someone:somewhere.com",
name: "Awesome Room",
isDirect: false,
avatarURL: nil,
avatarURL: avatarURL,
lastMessage: nil,
lastMessageFormattedTimestamp: nil,
unreadNotificationCount: 0,
canonicalAlias: alias)
let inviter = RoomMemberProxyMock()
inviter.displayName = "Luca"
inviter.userID = "@jack:somewhere.com"
inviter.avatarURL = nil
inviter.userID = "@jack:somewhi.nl"
inviter.avatarURL = avatarURL
return .init(roomDetails: dmRoom, inviter: inviter, isUnread: true)
return .init(roomDetails: dmRoom, inviter: inviter, isUnread: isUnread)
}
}
@@ -26,36 +26,35 @@ struct RoomDetailsEditScreen: View {
}
var body: some View {
mainContent
.elementFormStyle()
.scrollDismissesKeyboard(.immediately)
.navigationTitle(L10n.screenRoomDetailsEditRoomTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.actionCancel) {
context.send(viewAction: .cancel)
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(L10n.actionSave) {
context.send(viewAction: .save)
focus = nil
}
.disabled(!context.viewState.canSave)
}
}
}
// MARK: - Private
private var mainContent: some View {
Form {
avatar
nameSection
topicSection
}
.elementFormStyle()
.scrollDismissesKeyboard(.immediately)
.navigationTitle(L10n.screenRoomDetailsEditRoomTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
}
// MARK: - Private
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.actionCancel) {
context.send(viewAction: .cancel)
}
}
ToolbarItem(placement: .confirmationAction) {
Button(L10n.actionSave) {
context.send(viewAction: .save)
focus = nil
}
.disabled(!context.viewState.canSave)
}
}
private var avatar: some View {
@@ -89,7 +88,7 @@ struct RoomDetailsEditScreen: View {
TextField(L10n.commonRoomName,
text: $context.name,
prompt: canEditName ? Text(L10n.screenCreateRoomRoomNamePlaceholder) : nil,
prompt: canEditName ? Text(L10n.commonRoomNamePlaceholder) : nil,
axis: .horizontal)
.focused($focus, equals: .name)
.font(.compound.bodyLG)
@@ -110,7 +109,7 @@ struct RoomDetailsEditScreen: View {
TextField(L10n.commonTopic,
text: $context.topic,
prompt: canEditTopic ? Text(L10n.screenCreateRoomTopicPlaceholder) : nil,
prompt: canEditTopic ? Text(L10n.commonTopicPlaceholder) : nil,
axis: .vertical)
.focused($focus, equals: .topic)
.font(.compound.bodyLG)
@@ -127,7 +126,8 @@ struct RoomDetailsEditScreen: View {
private var avatarOverlayIcon: some View {
Image(systemName: "camera")
.padding(2)
.font(.system(size: 14, weight: .semibold))
.padding(3)
.imageScale(.small)
.foregroundColor(.white)
.background {
@@ -153,7 +153,7 @@ class StartChatScreenViewModel: StartChatScreenViewModelType, StartChatScreenVie
private func showLoadingIndicator() {
userIndicatorController?.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal,
type: .modal(interactiveDismissDisabled: true),
title: L10n.commonLoading,
persistent: true))
}
@@ -29,6 +29,7 @@ struct StartChatScreen: View {
}
}
.elementFormStyle()
.scrollDismissesKeyboard(.immediately)
.navigationTitle(L10n.actionStartChat)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -64,7 +65,8 @@ struct StartChatScreen: View {
private var createRoomSection: some View {
Section {
Button(action: createRoom) {
Label(L10n.actionCreateARoom, systemImage: "person.3")
Label(L10n.screenCreateRoomActionCreateRoom, systemImage: "person.3")
.imageScale(.small)
}
.buttonStyle(FormButtonStyle(accessory: .navigationLink))
}
@@ -99,6 +101,8 @@ struct StartChatScreen: View {
}
.listRowSeparator(.automatic)
.formSectionStyle()
} else {
Section.empty
}
}