diff --git a/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFilterModels.swift b/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFilterModels.swift index 9376e1509..1f006a175 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFilterModels.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFilterModels.swift @@ -73,16 +73,15 @@ enum RoomListFilter: Int, CaseIterable, Identifiable { struct RoomListFiltersState { private(set) var activeFilters: OrderedSet - private var inactiveFilters: OrderedSet init(activeFilters: OrderedSet = []) { self.activeFilters = .init(activeFilters) - inactiveFilters = OrderedSet(RoomListFilter.allCases).subtracting(activeFilters) } var availableFilters: [RoomListFilter] { - var availableFilters = inactiveFilters + var availableFilters = OrderedSet(RoomListFilter.allCases) for filter in activeFilters { + availableFilters.remove(filter) if let incompatibleFilter = filter.incompatibleFilter { availableFilters.remove(incompatibleFilter) } @@ -90,6 +89,10 @@ struct RoomListFiltersState { return availableFilters.elements } + var orderedFilters: [RoomListFilter] { + activeFilters.elements + availableFilters + } + var isFiltering: Bool { !activeFilters.isEmpty } @@ -101,20 +104,14 @@ struct RoomListFiltersState { } // We always want the most recently enabled filter to be at the bottom of the others. activeFilters.append(filter) - inactiveFilters.remove(filter) } mutating func deactivateFilter(_ filter: RoomListFilter) { activeFilters.remove(filter) - // We always want the most recently disabled filter to be on top of the others - inactiveFilters.insert(filter, at: 0) } mutating func clearFilters() { - // We iterate in reverse because filters should get disabled from the first to last that has been used. - for filter in activeFilters.reversed() { - deactivateFilter(filter) - } + activeFilters.removeAll() } func isFilterActive(_ filter: RoomListFilter) -> Bool { diff --git a/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFilterView.swift b/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFilterView.swift index 83900ada1..ac200d3f7 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFilterView.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFilterView.swift @@ -64,9 +64,7 @@ private struct FilterToggleStyle: ToggleStyle { .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() - } + configuration.isOn.toggle() } } } diff --git a/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFiltersView.swift b/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFiltersView.swift index 30e6da844..83c6734f1 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFiltersView.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/Filters/RoomListFiltersView.swift @@ -17,39 +17,50 @@ import SwiftUI struct RoomListFiltersView: View { + let leadingID = "leading" @Binding var state: RoomListFiltersState @Namespace private var namespace var body: some View { - ScrollView(.horizontal) { - HStack(spacing: 8) { - if state.isFiltering { - clearButton - } - - ForEach(state.activeFilters) { filter in - RoomListFilterView(filter: filter, - isActive: getBinding(for: filter)) - .matchedGeometryEffect(id: filter.id, in: namespace) - // This will make the animation always render the enabled ones on top - .zIndex(1) - } - ForEach(state.availableFilters) { filter in - RoomListFilterView(filter: filter, - isActive: getBinding(for: filter)) - .matchedGeometryEffect(id: filter.id, in: namespace) + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack(spacing: 0) { + // Using an empty view makes the scroll a bit clunky, better a 0 frame spacer + Spacer() + .frame(width: 0, height: 0) + .id(leadingID) + + HStack(spacing: 8) { + if state.isFiltering { + clearButton(scrollViewProxy: proxy) + } + + ForEach(state.activeFilters) { filter in + RoomListFilterView(filter: filter, + isActive: getBinding(for: filter, scrollViewProxy: proxy)) + .matchedGeometryEffect(id: filter.id, in: namespace) + // This will make the animation always render the enabled ones on top + .zIndex(1) + } + ForEach(state.availableFilters) { filter in + RoomListFilterView(filter: filter, + isActive: getBinding(for: filter, scrollViewProxy: proxy)) + .matchedGeometryEffect(id: filter.id, in: namespace) + } + } } } + .scrollIndicators(.hidden) .padding(.leading, 16) .padding(.vertical, 12) } - .scrollIndicators(.hidden) } - private var clearButton: some View { + private func clearButton(scrollViewProxy: ScrollViewProxy) -> some View { Button(action: { - withAnimation(.elementDefault) { + withAnimation(.easeInOut(duration: 0.2).disabledDuringTests()) { state.clearFilters() + scrollViewProxy.scrollTo(leadingID, anchor: .leading) } }, label: { Image(systemName: "xmark.circle.fill") @@ -58,11 +69,18 @@ struct RoomListFiltersView: View { }) } - private func getBinding(for filter: RoomListFilter) -> Binding { + private func getBinding(for filter: RoomListFilter, scrollViewProxy: ScrollViewProxy) -> Binding { Binding(get: { state.isFilterActive(filter) }, set: { isEnabled, _ in - isEnabled ? state.activateFilter(filter) : state.deactivateFilter(filter) + withAnimation(.easeInOut(duration: 0.2).disabledDuringTests()) { + if isEnabled { + state.activateFilter(filter) + scrollViewProxy.scrollTo(leadingID, anchor: .leading) + } else { + state.deactivateFilter(filter) + } + } }) } } diff --git a/UnitTests/Sources/RoomListFiltersStateTests.swift b/UnitTests/Sources/RoomListFiltersStateTests.swift index f5488e9b9..786330cbd 100644 --- a/UnitTests/Sources/RoomListFiltersStateTests.swift +++ b/UnitTests/Sources/RoomListFiltersStateTests.swift @@ -51,7 +51,7 @@ final class RoomListFiltersStateTests: XCTestCase { state.deactivateFilter(.people) XCTAssertFalse(state.isFiltering) XCTAssertEqual(state.activeFilters, []) - XCTAssertEqual(state.availableFilters, [.people, .unreads, .rooms, .favourites]) + XCTAssertEqual(state.availableFilters, RoomListFilter.allCases) state.activateFilter(.rooms) XCTAssertTrue(state.isFiltering) @@ -80,7 +80,7 @@ final class RoomListFiltersStateTests: XCTestCase { state.clearFilters() XCTAssertFalse(state.isFiltering) XCTAssertEqual(state.activeFilters, []) - XCTAssertEqual(state.availableFilters, [.people, .unreads, .favourites, .rooms]) + XCTAssertEqual(state.availableFilters, RoomListFilter.allCases) } func testOrder() { @@ -90,11 +90,11 @@ final class RoomListFiltersStateTests: XCTestCase { state.deactivateFilter(.favourites) XCTAssertEqual(state.activeFilters, []) - XCTAssertEqual(state.availableFilters, [.favourites, .unreads, .people, .rooms]) + XCTAssertEqual(state.availableFilters, RoomListFilter.allCases) state.activateFilter(.rooms) XCTAssertEqual(state.activeFilters, [.rooms]) - XCTAssertEqual(state.availableFilters, [.favourites, .unreads]) + XCTAssertEqual(state.availableFilters, [.unreads, .favourites]) state.activateFilter(.unreads) XCTAssertEqual(state.activeFilters, [.rooms, .unreads])