Various UI tweaks (#1112)

* Tweak invites button frame and padding

* Implement persistent selections on the room list

* Stop showing rooms when focusing the room list search field with an empty query

* Tweaks after code review

* Improve invite button UI

* Fix unit tests
This commit is contained in:
Stefan Ceriu
2023-06-20 12:20:26 +03:00
committed by GitHub
parent 3bbc16fd64
commit 863492985d
14 changed files with 199 additions and 107 deletions
@@ -36,6 +36,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private let sidebarNavigationStackCoordinator: NavigationStackCoordinator
private let detailNavigationStackCoordinator: NavigationStackCoordinator
private let selectedRoomSubject = CurrentValueSubject<String?, Never>(nil)
var callback: ((UserSessionFlowCoordinatorAction) -> Void)?
init(userSession: UserSessionProtocol,
@@ -109,7 +111,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
// MARK: - Private
// swiftlint:disable:next cyclomatic_complexity
// swiftlint:disable:next cyclomatic_complexity function_body_length
private func setupStateMachine() {
stateMachine.addTransitionHandler { [weak self] context in
guard let self else { return }
@@ -160,6 +162,15 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
}
stateMachine.addTransitionHandler { [weak self] context in
switch context.toState {
case .roomList(let selectedRoomId):
self?.selectedRoomSubject.send(selectedRoomId)
default:
break
}
}
stateMachine.addErrorHandler { context in
fatalError("Failed transition with context: \(context)")
}
@@ -170,7 +181,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
let parameters = HomeScreenCoordinatorParameters(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder(),
bugReportService: bugReportService,
navigationStackCoordinator: detailNavigationStackCoordinator)
navigationStackCoordinator: detailNavigationStackCoordinator,
selectedRoomPublisher: selectedRoomSubject.asCurrentValuePublisher())
let coordinator = HomeScreenCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in
@@ -14,6 +14,7 @@
// limitations under the License.
//
import Combine
import SwiftUI
/// A view modifier that applies a shimmering effect to the view.
@@ -72,12 +73,13 @@ extension View {
struct ShimmerOverlay_Previews: PreviewProvider {
static let viewModel = HomeScreenViewModel(userSession: MockUserSession(clientProxy: MockClientProxy(userID: ""),
mediaProvider: MockMediaProvider()),
attributedStringBuilder: AttributedStringBuilder())
attributedStringBuilder: AttributedStringBuilder(),
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher())
static var previews: some View {
VStack {
ForEach(0...8, id: \.self) { _ in
HomeScreenRoomCell(room: .placeholder(), context: viewModel.context)
HomeScreenRoomCell(room: .placeholder(), context: viewModel.context, isSelected: false)
}
}
.redacted(reason: .placeholder)
@@ -22,6 +22,7 @@ struct HomeScreenCoordinatorParameters {
let attributedStringBuilder: AttributedStringBuilderProtocol
let bugReportService: BugReportServiceProtocol
let navigationStackCoordinator: NavigationStackCoordinator
let selectedRoomPublisher: CurrentValuePublisher<String?, Never>
}
enum HomeScreenCoordinatorAction {
@@ -48,7 +49,8 @@ final class HomeScreenCoordinator: CoordinatorProtocol {
self.parameters = parameters
viewModel = HomeScreenViewModel(userSession: parameters.userSession,
attributedStringBuilder: parameters.attributedStringBuilder)
attributedStringBuilder: parameters.attributedStringBuilder,
selectedRoomPublisher: parameters.selectedRoomPublisher)
viewModel.callback = { [weak self] action in
guard let self else { return }
@@ -74,6 +74,8 @@ struct HomeScreenViewState: BindableState {
var hasPendingInvitations = false
var hasUnreadPendingInvitations = false
var selectedRoomID: String?
var visibleRooms: [HomeScreenRoom] {
if roomListMode == .skeletons {
return placeholderRooms
@@ -31,7 +31,9 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
var callback: ((HomeScreenViewModelAction) -> Void)?
// swiftlint:disable:next function_body_length cyclomatic_complexity
init(userSession: UserSessionProtocol, attributedStringBuilder: AttributedStringBuilderProtocol) {
init(userSession: UserSessionProtocol,
attributedStringBuilder: AttributedStringBuilderProtocol,
selectedRoomPublisher: CurrentValuePublisher<String?, Never>) {
self.userSession = userSession
self.attributedStringBuilder = attributedStringBuilder
@@ -59,6 +61,10 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
.weakAssign(to: \.state.userAvatarURL, on: self)
.store(in: &cancellables)
selectedRoomPublisher
.weakAssign(to: \.state.selectedRoomID, on: self)
.store(in: &cancellables)
guard let roomSummaryProvider, let inviteSummaryProvider else {
MXLog.error("Room summary provider unavailable")
return
@@ -14,6 +14,7 @@
// limitations under the License.
//
import Combine
import Compound
import SwiftUI
@@ -25,27 +26,27 @@ struct HomeScreen: View {
@ObservedObject var context: HomeScreenViewModel.Context
@State private var scrollViewAdapter = ScrollViewAdapter()
@State private var showingLogoutConfirmation = false
@State private var showingBottomToolbar = true
@State private var isSearching = false
var body: some View {
ScrollView {
if context.viewState.showSessionVerificationBanner {
sessionVerificationBanner
}
if context.viewState.hasPendingInvitations {
if context.viewState.hasPendingInvitations, !isSearching {
HomeScreenInvitesButton(title: L10n.actionInvitesList, hasBadge: context.viewState.hasUnreadPendingInvitations) {
context.send(viewAction: .selectInvites)
}
.padding(.trailing, 16)
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.vertical, -8.0)
}
if context.viewState.roomListMode == .skeletons {
LazyVStack(spacing: 0) {
ForEach(context.viewState.visibleRooms) { room in
HomeScreenRoomCell(room: room, context: context)
HomeScreenRoomCell(room: room, context: context, isSelected: false)
.redacted(reason: .placeholder)
}
}
@@ -53,29 +54,7 @@ struct HomeScreen: View {
.disabled(true)
} else {
LazyVStack(spacing: 0) {
ForEach(context.viewState.visibleRooms) { room in
Group {
if room.isPlaceholder {
HomeScreenRoomCell(room: room, context: context)
.redacted(reason: .placeholder)
} else {
HomeScreenRoomCell(room: room, context: context)
.contextMenu {
Button {
context.send(viewAction: .showRoomDetails(roomIdentifier: room.id))
} label: {
Label(L10n.commonSettings, systemImage: "gearshape")
}
Button(role: .destructive) {
context.send(viewAction: .leaveRoom(roomIdentifier: room.id))
} label: {
Label(L10n.actionLeaveRoom, systemImage: "rectangle.portrait.and.arrow.right")
}
}
}
}
}
HomeScreenRoomList(context: context, isSearching: $isSearching)
}
.searchable(text: $context.searchQuery)
.compoundSearchField()
@@ -132,7 +111,7 @@ struct HomeScreen: View {
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
userMenuButton
HomeScreenUserMenuButton(context: context)
}
ToolbarItemGroup(placement: .bottomBar) {
@@ -140,51 +119,11 @@ struct HomeScreen: View {
newRoomButton
}
}
@ViewBuilder
private var userMenuButton: some View {
Menu {
Section {
Button(action: settings) {
Label(L10n.commonSettings, systemImage: "gearshape")
}
}
Section {
MatrixUserShareLink(userID: context.viewState.userID) {
Label(L10n.actionInvite, systemImage: "square.and.arrow.up")
}
Button(action: feedback) {
Label(L10n.commonReportABug, systemImage: "ladybug")
}
}
Section {
Button(role: .destructive) {
showingLogoutConfirmation = true
} label: {
Label(L10n.screenSignoutPreferenceItem, systemImage: "rectangle.portrait.and.arrow.right")
}
}
} label: {
LoadableAvatarImage(url: context.viewState.userAvatarURL,
name: context.viewState.userDisplayName,
contentID: context.viewState.userID,
avatarSize: .user(on: .home),
imageProvider: context.imageProvider)
.accessibilityIdentifier(A11yIdentifiers.homeScreen.userAvatar)
}
.alert(L10n.screenSignoutConfirmationDialogTitle,
isPresented: $showingLogoutConfirmation) {
Button(L10n.screenSignoutConfirmationDialogSubmit,
role: .destructive,
action: signOut)
} message: {
Text(L10n.screenSignoutConfirmationDialogContent)
}
.accessibilityLabel(L10n.a11yUserMenu)
}
private var newRoomButton: some View {
Button(action: startChat) {
Button {
context.send(viewAction: .startChat)
} label: {
Image(systemName: "square.and.pencil")
}
}
@@ -224,22 +163,6 @@ struct HomeScreen: View {
.cornerRadius(14)
.padding(.horizontal, 16)
}
private func settings() {
context.send(viewAction: .userMenu(action: .settings))
}
private func startChat() {
context.send(viewAction: .startChat)
}
private func feedback() {
context.send(viewAction: .userMenu(action: .feedback))
}
private func signOut() {
context.send(viewAction: .userMenu(action: .signOut))
}
private func updateVisibleRange() {
guard let scrollView = scrollViewAdapter.scrollView,
@@ -292,7 +215,8 @@ struct HomeScreen_Previews: PreviewProvider {
mediaProvider: MockMediaProvider())
let viewModel = HomeScreenViewModel(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder())
attributedStringBuilder: AttributedStringBuilder(),
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher())
return NavigationStack {
HomeScreen(context: viewModel.context)
@@ -25,7 +25,7 @@ struct HomeScreenInvitesButton: View {
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
HStack(spacing: 8.0) {
Text(title)
.foregroundColor(.compound.textPrimary)
.font(.compound.bodyMDSemibold)
@@ -34,7 +34,9 @@ struct HomeScreenInvitesButton: View {
badge
}
}
.padding(.vertical, 4)
.padding(.trailing, 16.0)
.padding(.leading, 64.0)
.padding(.vertical, 8.0)
}
}
@@ -14,6 +14,7 @@
// limitations under the License.
//
import Combine
import SwiftUI
struct HomeScreenRoomCell: View {
@@ -22,6 +23,7 @@ struct HomeScreenRoomCell: View {
let room: HomeScreenRoom
let context: HomeScreenViewModel.Context
let isSelected: Bool
private let verticalInsets = 12.0
private let horizontalInsets = 16.0
@@ -47,7 +49,7 @@ struct HomeScreenRoomCell: View {
.padding(.horizontal, horizontalInsets)
.accessibilityElement(children: .combine)
}
.buttonStyle(HomeScreenRoomCellButtonStyle(isSelected: false))
.buttonStyle(HomeScreenRoomCellButtonStyle(isSelected: isSelected))
.accessibilityIdentifier(A11yIdentifiers.homeScreen.roomName(room.name))
}
@@ -149,8 +151,9 @@ struct HomeScreenRoomCellButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(configuration.isPressed || isSelected ? Color.compound.bgSubtleSecondary : .clear)
.background(isSelected ? Color.compound.bgSubtleSecondary : Color.compound.bgCanvasDefault)
.contentShape(Rectangle())
.animation(.elementDefault, value: isSelected)
}
}
@@ -171,7 +174,8 @@ struct HomeScreenRoomCell_Previews: PreviewProvider {
mediaProvider: MockMediaProvider())
let viewModel = HomeScreenViewModel(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder())
attributedStringBuilder: AttributedStringBuilder(),
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher())
let rooms: [HomeScreenRoom] = summaryProvider.roomListPublisher.value.compactMap { summary -> HomeScreenRoom? in
switch summary {
@@ -190,14 +194,14 @@ struct HomeScreenRoomCell_Previews: PreviewProvider {
return VStack(spacing: 0) {
ForEach(rooms) { room in
HomeScreenRoomCell(room: room, context: viewModel.context)
HomeScreenRoomCell(room: room, context: viewModel.context, isSelected: false)
}
HomeScreenRoomCell(room: .placeholder(), context: viewModel.context)
HomeScreenRoomCell(room: .placeholder(), context: viewModel.context, isSelected: false)
.redacted(reason: .placeholder)
HomeScreenRoomCell(room: .placeholder(), context: viewModel.context)
HomeScreenRoomCell(room: .placeholder(), context: viewModel.context, isSelected: false)
.redacted(reason: .placeholder)
HomeScreenRoomCell(room: .placeholder(), context: viewModel.context)
HomeScreenRoomCell(room: .placeholder(), context: viewModel.context, isSelected: false)
.redacted(reason: .placeholder)
}
}
@@ -0,0 +1,59 @@
//
// 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
struct HomeScreenRoomList: View {
@Environment(\.isSearching) var isSearchFieldFocused
@ObservedObject var context: HomeScreenViewModel.Context
@Binding var isSearching: Bool
var body: some View {
content
.onChange(of: isSearchFieldFocused) { isSearching = $0 }
}
@ViewBuilder
private var content: some View {
if isSearchFieldFocused, context.searchQuery.count == 0 {
EmptyView()
} else {
ForEach(context.viewState.visibleRooms) { room in
if room.isPlaceholder {
HomeScreenRoomCell(room: room, context: context, isSelected: false)
.redacted(reason: .placeholder)
} else {
let isSelected = context.viewState.selectedRoomID == room.id
HomeScreenRoomCell(room: room, context: context, isSelected: isSelected)
.contextMenu {
Button {
context.send(viewAction: .showRoomDetails(roomIdentifier: room.id))
} label: {
Label(L10n.commonSettings, systemImage: "gearshape")
}
Button(role: .destructive) {
context.send(viewAction: .leaveRoom(roomIdentifier: room.id))
} label: {
Label(L10n.actionLeaveRoom, systemImage: "rectangle.portrait.and.arrow.right")
}
}
}
}
}
}
}
@@ -0,0 +1,69 @@
//
// 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
struct HomeScreenUserMenuButton: View {
@State private var showingLogoutConfirmation = false
let context: HomeScreenViewModel.Context
var body: some View {
Menu {
Section {
Button {
context.send(viewAction: .userMenu(action: .settings))
} label: {
Label(L10n.commonSettings, systemImage: "gearshape")
}
}
Section {
MatrixUserShareLink(userID: context.viewState.userID) {
Label(L10n.actionInvite, systemImage: "square.and.arrow.up")
}
Button {
context.send(viewAction: .userMenu(action: .feedback))
} label: {
Label(L10n.commonReportABug, systemImage: "ladybug")
}
}
Section {
Button(role: .destructive) {
showingLogoutConfirmation = true
} label: {
Label(L10n.screenSignoutPreferenceItem, systemImage: "rectangle.portrait.and.arrow.right")
}
}
} label: {
LoadableAvatarImage(url: context.viewState.userAvatarURL,
name: context.viewState.userDisplayName,
contentID: context.viewState.userID,
avatarSize: .user(on: .home),
imageProvider: context.imageProvider)
.accessibilityIdentifier(A11yIdentifiers.homeScreen.userAvatar)
}
.alert(L10n.screenSignoutConfirmationDialogTitle,
isPresented: $showingLogoutConfirmation) {
Button(L10n.screenSignoutConfirmationDialogSubmit,
role: .destructive) {
context.send(viewAction: .userMenu(action: .signOut))
}
} message: {
Text(L10n.screenSignoutConfirmationDialogContent)
}
.accessibilityLabel(L10n.a11yUserMenu)
}
}
@@ -117,7 +117,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
// MARK: - Private
// swiftlint:disable:next function_body_length
private func setupSubscriptions() {
timelineController.callbacks
.receive(on: DispatchQueue.main)
@@ -134,7 +134,8 @@ class MockScreen: Identifiable {
let coordinator = HomeScreenCoordinator(parameters: .init(userSession: session,
attributedStringBuilder: AttributedStringBuilder(),
bugReportService: BugReportServiceMock(),
navigationStackCoordinator: navigationStackCoordinator))
navigationStackCoordinator: navigationStackCoordinator,
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher()))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .settings: