mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 19:44:05 +00:00
New filters ordering and faster animation (#2536)
This commit is contained in:
@@ -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])
|
||||||
|
|||||||
Reference in New Issue
Block a user