New filters ordering and faster animation (#2536)

This commit is contained in:
Mauro
2024-03-07 10:13:34 +01:00
committed by GitHub
parent 129b898f4f
commit 82a5d34dd7
4 changed files with 52 additions and 39 deletions
@@ -73,16 +73,15 @@ enum RoomListFilter: Int, CaseIterable, Identifiable {
struct RoomListFiltersState { struct RoomListFiltersState {
private(set) var activeFilters: OrderedSet<RoomListFilter> private(set) var activeFilters: OrderedSet<RoomListFilter>
private var inactiveFilters: OrderedSet<RoomListFilter>
init(activeFilters: OrderedSet<RoomListFilter> = []) { init(activeFilters: OrderedSet<RoomListFilter> = []) {
self.activeFilters = .init(activeFilters) self.activeFilters = .init(activeFilters)
inactiveFilters = OrderedSet(RoomListFilter.allCases).subtracting(activeFilters)
} }
var availableFilters: [RoomListFilter] { var availableFilters: [RoomListFilter] {
var availableFilters = inactiveFilters var availableFilters = OrderedSet(RoomListFilter.allCases)
for filter in activeFilters { for filter in activeFilters {
availableFilters.remove(filter)
if let incompatibleFilter = filter.incompatibleFilter { if let incompatibleFilter = filter.incompatibleFilter {
availableFilters.remove(incompatibleFilter) availableFilters.remove(incompatibleFilter)
} }
@@ -90,6 +89,10 @@ struct RoomListFiltersState {
return availableFilters.elements return availableFilters.elements
} }
var orderedFilters: [RoomListFilter] {
activeFilters.elements + availableFilters
}
var isFiltering: Bool { var isFiltering: Bool {
!activeFilters.isEmpty !activeFilters.isEmpty
} }
@@ -101,20 +104,14 @@ struct RoomListFiltersState {
} }
// We always want the most recently enabled filter to be at the bottom of the others. // We always want the most recently enabled filter to be at the bottom of the others.
activeFilters.append(filter) activeFilters.append(filter)
inactiveFilters.remove(filter)
} }
mutating func deactivateFilter(_ filter: RoomListFilter) { mutating func deactivateFilter(_ filter: RoomListFilter) {
activeFilters.remove(filter) 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() { mutating func clearFilters() {
// We iterate in reverse because filters should get disabled from the first to last that has been used. activeFilters.removeAll()
for filter in activeFilters.reversed() {
deactivateFilter(filter)
}
} }
func isFilterActive(_ filter: RoomListFilter) -> Bool { func isFilterActive(_ filter: RoomListFilter) -> Bool {
@@ -64,9 +64,7 @@ private struct FilterToggleStyle: ToggleStyle {
.drawingGroup() .drawingGroup()
// The button breaks the animation for some reason, so better to use the label directly with an onTapGesture // The button breaks the animation for some reason, so better to use the label directly with an onTapGesture
.onTapGesture { .onTapGesture {
withAnimation(.elementDefault) {
configuration.isOn.toggle() configuration.isOn.toggle()
} }
} }
}
} }
@@ -17,39 +17,50 @@
import SwiftUI import SwiftUI
struct RoomListFiltersView: View { struct RoomListFiltersView: View {
let leadingID = "leading"
@Binding var state: RoomListFiltersState @Binding var state: RoomListFiltersState
@Namespace private var namespace @Namespace private var namespace
var body: some View { var body: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal) { 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) { HStack(spacing: 8) {
if state.isFiltering { if state.isFiltering {
clearButton clearButton(scrollViewProxy: proxy)
} }
ForEach(state.activeFilters) { filter in ForEach(state.activeFilters) { filter in
RoomListFilterView(filter: filter, RoomListFilterView(filter: filter,
isActive: getBinding(for: filter)) isActive: getBinding(for: filter, scrollViewProxy: proxy))
.matchedGeometryEffect(id: filter.id, in: namespace) .matchedGeometryEffect(id: filter.id, in: namespace)
// This will make the animation always render the enabled ones on top // This will make the animation always render the enabled ones on top
.zIndex(1) .zIndex(1)
} }
ForEach(state.availableFilters) { filter in ForEach(state.availableFilters) { filter in
RoomListFilterView(filter: filter, RoomListFilterView(filter: filter,
isActive: getBinding(for: filter)) isActive: getBinding(for: filter, scrollViewProxy: proxy))
.matchedGeometryEffect(id: filter.id, in: namespace) .matchedGeometryEffect(id: filter.id, in: namespace)
} }
} }
}
}
.scrollIndicators(.hidden)
.padding(.leading, 16) .padding(.leading, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
} }
.scrollIndicators(.hidden)
} }
private var clearButton: some View { private func clearButton(scrollViewProxy: ScrollViewProxy) -> some View {
Button(action: { Button(action: {
withAnimation(.elementDefault) { withAnimation(.easeInOut(duration: 0.2).disabledDuringTests()) {
state.clearFilters() state.clearFilters()
scrollViewProxy.scrollTo(leadingID, anchor: .leading)
} }
}, label: { }, label: {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
@@ -58,11 +69,18 @@ struct RoomListFiltersView: View {
}) })
} }
private func getBinding(for filter: RoomListFilter) -> Binding<Bool> { private func getBinding(for filter: RoomListFilter, scrollViewProxy: ScrollViewProxy) -> Binding<Bool> {
Binding<Bool>(get: { Binding<Bool>(get: {
state.isFilterActive(filter) state.isFilterActive(filter)
}, set: { isEnabled, _ in }, 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)
}
}
}) })
} }
} }
@@ -51,7 +51,7 @@ final class RoomListFiltersStateTests: XCTestCase {
state.deactivateFilter(.people) state.deactivateFilter(.people)
XCTAssertFalse(state.isFiltering) XCTAssertFalse(state.isFiltering)
XCTAssertEqual(state.activeFilters, []) XCTAssertEqual(state.activeFilters, [])
XCTAssertEqual(state.availableFilters, [.people, .unreads, .rooms, .favourites]) XCTAssertEqual(state.availableFilters, RoomListFilter.allCases)
state.activateFilter(.rooms) state.activateFilter(.rooms)
XCTAssertTrue(state.isFiltering) XCTAssertTrue(state.isFiltering)
@@ -80,7 +80,7 @@ final class RoomListFiltersStateTests: XCTestCase {
state.clearFilters() state.clearFilters()
XCTAssertFalse(state.isFiltering) XCTAssertFalse(state.isFiltering)
XCTAssertEqual(state.activeFilters, []) XCTAssertEqual(state.activeFilters, [])
XCTAssertEqual(state.availableFilters, [.people, .unreads, .favourites, .rooms]) XCTAssertEqual(state.availableFilters, RoomListFilter.allCases)
} }
func testOrder() { func testOrder() {
@@ -90,11 +90,11 @@ final class RoomListFiltersStateTests: XCTestCase {
state.deactivateFilter(.favourites) state.deactivateFilter(.favourites)
XCTAssertEqual(state.activeFilters, []) XCTAssertEqual(state.activeFilters, [])
XCTAssertEqual(state.availableFilters, [.favourites, .unreads, .people, .rooms]) XCTAssertEqual(state.availableFilters, RoomListFilter.allCases)
state.activateFilter(.rooms) state.activateFilter(.rooms)
XCTAssertEqual(state.activeFilters, [.rooms]) XCTAssertEqual(state.activeFilters, [.rooms])
XCTAssertEqual(state.availableFilters, [.favourites, .unreads]) XCTAssertEqual(state.availableFilters, [.unreads, .favourites])
state.activateFilter(.unreads) state.activateFilter(.unreads)
XCTAssertEqual(state.activeFilters, [.rooms, .unreads]) XCTAssertEqual(state.activeFilters, [.rooms, .unreads])