UI POC for room list filters (#2382)

Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>
This commit is contained in:
Mauro
2024-01-26 10:43:05 +01:00
committed by GitHub
parent e755be4cda
commit 25ec8d2dcb
17 changed files with 337 additions and 39 deletions
@@ -97,6 +97,7 @@ struct HomeScreenViewState: BindableState {
var rooms: [HomeScreenRoom] = []
var roomListMode: HomeScreenRoomListMode = .skeletons
var shouldShowFilters = false
var hasPendingInvitations = false
var hasUnreadPendingInvitations = false
@@ -111,6 +112,8 @@ struct HomeScreenViewState: BindableState {
return rooms
}
var filtersState = RoomListFiltersState()
var bindings = HomeScreenViewStateBindings()
var placeholderRooms: [HomeScreenRoom] {
@@ -175,3 +178,88 @@ struct HomeScreenRoom: Identifiable, Equatable {
isPlaceholder: true)
}
}
enum RoomListFilter: Int, CaseIterable, Identifiable {
var id: Int {
rawValue
}
case people
case rooms
case unreads
case favourites
case lowPriority
var localizedName: String {
switch self {
case .people:
return L10n.screenRoomlistFilterPeople
case .rooms:
return L10n.screenRoomlistFilterRooms
case .unreads:
return L10n.screenRoomlistFilterUnreads
case .favourites:
return L10n.screenRoomlistFilterFavourites
case .lowPriority:
return L10n.screenRoomlistFilterLowPriority
}
}
var complementaryFilter: RoomListFilter? {
switch self {
case .people:
return .rooms
case .rooms:
return .people
case .unreads:
return nil
case .favourites:
return .lowPriority
case .lowPriority:
return .favourites
}
}
}
final class RoomListFiltersState: ObservableObject {
@Published private var enabledFilters: Set<RoomListFilter>
init(enabledFilters: Set<RoomListFilter> = []) {
self.enabledFilters = enabledFilters
}
var sortedEnabledFilters: [RoomListFilter] {
enabledFilters.sorted(by: { $0.rawValue < $1.rawValue })
}
var sortedAvailableFilters: [RoomListFilter] {
var availableFilters = Set(RoomListFilter.allCases)
for filter in enabledFilters {
availableFilters.remove(filter)
if let complementaryFilter = filter.complementaryFilter {
availableFilters.remove(complementaryFilter)
}
}
return availableFilters.sorted(by: { $0.rawValue < $1.rawValue })
}
var isFiltering: Bool {
!enabledFilters.isEmpty
}
func set(_ filter: RoomListFilter, isEnabled: Bool) {
if isEnabled {
enabledFilters.insert(filter)
} else {
enabledFilters.remove(filter)
}
}
func clearFilters() {
enabledFilters.removeAll()
}
func isEnabled(_ filter: RoomListFilter) -> Bool {
enabledFilters.contains(filter)
}
}
@@ -80,6 +80,10 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
.weakAssign(to: \.state.selectedRoomID, on: self)
.store(in: &cancellables)
appSettings.$roomListFiltersEnabled
.weakAssign(to: \.state.shouldShowFilters, on: self)
.store(in: &cancellables)
let isSearchFieldFocused = context.$viewState.map(\.bindings.isSearchFieldFocused)
let searchQuery = context.$viewState.map(\.bindings.searchQuery)
isSearchFieldFocused
@@ -333,7 +337,11 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
if room.isPublic {
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomId: roomId, isDM: room.isEncryptedOneToOneRoom, state: .public)
} else {
state.bindings.leaveRoomAlertItem = room.joinedMembersCount > 1 ? LeaveRoomAlertItem(roomId: roomId, isDM: room.isEncryptedOneToOneRoom, state: .private) : LeaveRoomAlertItem(roomId: roomId, isDM: room.isEncryptedOneToOneRoom, state: .empty)
state.bindings.leaveRoomAlertItem = if room.joinedMembersCount > 1 {
LeaveRoomAlertItem(roomId: roomId, isDM: room.isEncryptedOneToOneRoom, state: .private)
} else {
LeaveRoomAlertItem(roomId: roomId, isDM: room.isEncryptedOneToOneRoom, state: .empty)
}
}
}
}
@@ -0,0 +1,77 @@
//
// Copyright 2024 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 RoomListFilterView: View {
let filter: RoomListFilter
@StateObject var state: RoomListFiltersState
var body: some View {
let binding = Binding<Bool>(get: {
state.isEnabled(filter)
}, set: { isEnabled, _ in
state.set(filter, isEnabled: isEnabled)
})
Toggle(isOn: binding) {
Text(filter.localizedName)
}
.toggleStyle(FilterToggleStyle())
}
}
struct RoomListFilterView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
RoomListFilterView(filter: .people, state: .init())
RoomListFilterView(filter: .people, state: .init(enabledFilters: [.people]))
}
}
private struct FilterToggleStyle: ToggleStyle {
private func strokeColor(isOn: Bool) -> Color {
isOn ? .compound.bgActionPrimaryRest : .compound.borderInteractiveSecondary
}
private func backgroundColor(isOn: Bool) -> Color {
isOn ? .compound.bgActionPrimaryRest : .compound.bgCanvasDefault
}
private func foregroundColor(isOn: Bool) -> Color {
isOn ? .compound.textOnSolidPrimary : .compound.textPrimary
}
func makeBody(configuration: Configuration) -> some View {
let shape = RoundedRectangle(cornerRadius: 20)
configuration.label
.font(.compound.bodyLG)
.foregroundColor(foregroundColor(isOn: configuration.isOn))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(shape.fill(backgroundColor(isOn: configuration.isOn)))
.overlay {
shape
.inset(by: 0.5)
.stroke(strokeColor(isOn: configuration.isOn))
}
.drawingGroup()
// The button breaks the animation for some reason, so better to use the label directly with an onTapGesture
.onTapGesture {
withAnimation(.elementDefault) {
configuration.isOn.toggle()
}
}
}
}
@@ -0,0 +1,67 @@
//
// Copyright 2024 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 RoomListFiltersView: View {
@StateObject var state: RoomListFiltersState
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 8) {
if state.isFiltering {
clearButton
} else {
// This solves a weird issue withe the LazyHStack
// where it is resized when the button appears and disappears
clearButton
.hidden()
.frame(width: 0)
}
ForEach(state.sortedEnabledFilters) { filter in
RoomListFilterView(filter: filter, state: state)
}
ForEach(state.sortedAvailableFilters) { filter in
RoomListFilterView(filter: filter, state: state)
}
}
.padding(.leading, !state.isFiltering ? 8 : 16)
.padding(.vertical, 12)
}
.scrollIndicators(.hidden)
}
private var clearButton: some View {
Button(action: {
withAnimation(.elementDefault) {
state.clearFilters()
}
}, label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 24))
.foregroundColor(.compound.bgActionPrimaryRest)
})
}
}
// MARK: - Previews
struct RoomListFiltersView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
RoomListFiltersView(state: .init())
RoomListFiltersView(state: .init(enabledFilters: [.rooms, .favourites]))
}
}
@@ -52,10 +52,12 @@ struct HomeScreen: View {
.layoutPriority(1)
}
case .rooms:
topSection
LazyVStack(spacing: 0) {
HomeScreenRoomList(context: context)
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
Section {
HomeScreenRoomList(context: context)
} header: {
topSection
}
}
.searchable(text: $context.searchQuery)
.compoundSearchField()
@@ -191,20 +193,30 @@ struct HomeScreen: View {
@ViewBuilder
/// The session verification banner and invites button if either are needed.
private var topSection: some View {
if context.viewState.showSessionVerificationBanner {
HomeScreenSessionVerificationBanner(context: context)
} else if context.viewState.showRecoveryKeyConfirmationBanner {
HomeScreenRecoveryKeyConfirmationBanner(context: context)
}
if context.viewState.hasPendingInvitations, !context.isSearchFieldFocused {
HomeScreenInvitesButton(title: L10n.actionInvitesList, hasBadge: context.viewState.hasUnreadPendingInvitations) {
context.send(viewAction: .selectInvites)
VStack(spacing: 0) {
if context.viewState.shouldShowFilters {
filters
}
if context.viewState.showSessionVerificationBanner {
HomeScreenSessionVerificationBanner(context: context)
} else if context.viewState.showRecoveryKeyConfirmationBanner {
HomeScreenRecoveryKeyConfirmationBanner(context: context)
}
if context.viewState.hasPendingInvitations, !context.isSearchFieldFocused {
HomeScreenInvitesButton(title: L10n.actionInvitesList, hasBadge: context.viewState.hasUnreadPendingInvitations) {
context.send(viewAction: .selectInvites)
}
.accessibilityIdentifier(A11yIdentifiers.homeScreen.invites)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.accessibilityIdentifier(A11yIdentifiers.homeScreen.invites)
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.vertical, -8.0)
}
.background(Color.compound.bgCanvasDefault)
}
private var filters: some View {
RoomListFiltersView(state: context.viewState.filtersState)
}
@ToolbarContentBuilder
@@ -34,7 +34,6 @@ struct HomeScreenInvitesButton: View {
}
.padding(.trailing, 16.0)
.padding(.leading, 64.0)
.padding(.vertical, 8.0)
}
}