mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
UI POC for room list filters (#2382)
Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user