2
2
mirror of https://github.com/mastodon/mastodon-ios synced 2025-04-11 22:58:02 +02:00

Add custom menu for filter options to include explanatory text

Contributes to IOS-398
This commit is contained in:
shannon 2025-04-09 08:23:53 -04:00
parent 777f4d9b99
commit 0be0568542

View File

@ -51,10 +51,83 @@ class NotificationPolicyViewController: UIHostingController<
}
}
extension VerticalAlignment {
enum MenuAlign: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[.top]
}
}
static let menuAlign = VerticalAlignment(MenuAlign.self)
}
extension HorizontalAlignment {
enum MenuAlign: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[.trailing]
}
}
static let menuAlign = HorizontalAlignment(MenuAlign.self)
}
struct NotificationPolicyView: View {
@Namespace private var menuAnimation
@StateObject var viewModel: NotificationPolicyViewModel
@State var menuAnchor: CGPoint?
@State var readyToShowMenu: Bool = false
private let mainViewPositionPrefKey = "mainView"
private let menuPositionPrefKey = "menu"
var body: some View {
ZStack(alignment: Alignment(horizontal: .menuAlign, vertical: .menuAlign)) {
mainView()
.overlay {
ReferencePointReader(id: mainViewPositionPrefKey, referencePoint: .leadingTop)
}
.alignmentGuide(HorizontalAlignment.menuAlign) { d in
guard let menuAnchor else { return d[HorizontalAlignment.center] }
return menuAnchor.x
}
.alignmentGuide(VerticalAlignment.menuAlign) { d in
guard let menuAnchor else { return d[HorizontalAlignment.center] }
return menuAnchor.y
}
if readyToShowMenu, let menuItem = viewModel.isShowingMenu {
menu(for: menuItem)
.alignmentGuide(HorizontalAlignment.menuAlign) { d in
return d[HorizontalAlignment.trailing]
}
.alignmentGuide(VerticalAlignment.menuAlign) { d in
return d[VerticalAlignment.center]
}
}
}
.onDisappear {
Task {
let updatedPolicy = try await viewModel.saveChanges()
viewModel.didDismissView?(updatedPolicy)
}
}
.onPreferenceChange(PositionKey.self) { preferences in
menuAnchor = preferences.deltaFrom(mainViewPositionPrefKey, to: menuPositionPrefKey)
let canShowMenuNow = menuAnchor != nil
if canShowMenuNow != readyToShowMenu {
Task { @MainActor in
withAnimation {
readyToShowMenu = menuAnchor != nil
}
}
}
}
}
@ViewBuilder func mainView() -> some View {
VStack {
HStack {
Spacer()
@ -68,10 +141,10 @@ struct NotificationPolicyView: View {
}
}
.padding()
List {
ForEach(viewModel.sections, id: \.self) { section in
Section(header: Text(section.headerText).font(.title)) {
Section(header: Text(section.headerText).font(.title2)) {
ForEach(section.items, id: \.self) { policyItem in
rowView(policyItem)
}
@ -80,16 +153,11 @@ struct NotificationPolicyView: View {
}
}
.listStyle(.insetGrouped)
Spacer()
}
.background(Color(uiColor: .systemGroupedBackground))
.onDisappear {
Task {
let updatedPolicy = try await viewModel.saveChanges()
viewModel.didDismissView?(updatedPolicy)
}
}
}
@ViewBuilder func rowView(
@ -128,15 +196,28 @@ struct NotificationPolicyView: View {
switch settingType {
case .notFollowing, .notFollowers, .newAccounts, .privateMentions,
.limitedAccounts:
Picker("", selection: viewModel.binding(for: settingType)) {
ForEach([FilterAction.accept, .filter, .drop], id: \.self) {
option in
Text(option.displayTitle)
Button {
withAnimation {
if viewModel.isShowingMenu == nil {
viewModel.isShowingMenu = settingType
} else {
viewModel.isShowingMenu = nil
}
}
} label: {
HStack {
Text(viewModel.value(forItem: settingType).displayTitle)
Image(systemName: "chevron.up.chevron.down")
}
}
.pickerStyle(.menu)
.tint(Asset.Colors.Brand.blurple.swiftUIColor)
.fixedSize()
.transition(.identity)
.overlay {
if settingType == viewModel.isShowingMenu {
ReferencePointReader(id: menuPositionPrefKey, referencePoint: .trailingCenter)
}
}
case .adminReports, .adminSignups:
Toggle(
isOn: Binding(
@ -163,9 +244,61 @@ struct NotificationPolicyView: View {
}
}
extension NotificationPolicyView {
@ViewBuilder func menu(for filterItem: NotificationPolicyViewModel.NotificationFilterItem) -> some View {
VStack(alignment: .leading) {
ForEach([FilterAction.accept, .filter, .drop], id: \.self) { option in
HStack(alignment: .top, spacing: 0) {
let checkmarkWidth: CGFloat = 25
if viewModel.value(forItem: filterItem) == option {
Image(systemName: "checkmark")
.font(.caption)
.frame(width: checkmarkWidth, height: checkmarkWidth)
} else {
Spacer()
.frame(width: checkmarkWidth, height: checkmarkWidth)
}
VStack(alignment: .leading) {
Text(option.displayTitle)
Text(option.displaySubtitle)
.font(.caption2)
}
.padding(5)
}
.padding()
.fixedSize(horizontal: false, vertical: true)
.onTapGesture {
withAnimation {
if viewModel.value(forItem: filterItem) != option {
viewModel.setValue(option, forItem: filterItem)
}
viewModel.isShowingMenu = nil
}
}
if option != .drop {
Spacer()
.frame(height: 0.5)
.frame(maxWidth: .infinity)
.background(SeparatorShapeStyle())
}
}
}
.frame(width: 250)
.fixedSize(horizontal: false, vertical: true)
.background {
RoundedRectangle(cornerRadius: 10)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
.shadow(radius: 5)
}
}
}
@MainActor
class NotificationPolicyViewModel: ObservableObject {
let sections: [NotificationPolicyViewModel.NotificationFilterSection]
let originalRegularSettings: NotificationFilterSettings
@ -174,6 +307,7 @@ class NotificationPolicyViewModel: ObservableObject {
var dismissView: (() -> Void)?
var didDismissView: ((Mastodon.Entity.NotificationPolicy?) -> Void)?
@Published var isShowingMenu: NotificationFilterItem?
@Published var regularFilterSettings: NotificationFilterSettings
@Published var adminFilterSettings: AdminNotificationFilterSettings?
@ -444,3 +578,73 @@ extension FilterAction {
}
}
}
struct ReferencePointReader: View {
static let referenceSpace = "ReferencePointReaderSpace"
let id: String
let referencePoint: PositionReferencePoint
enum PositionReferencePoint {
case trailingCenter
case leadingTop
}
var body: some View {
GeometryReader { metrics in
let position = {
switch referencePoint {
case .trailingCenter:
CGPoint(
x: metrics.frame(in: .named(ReferencePointReader.referenceSpace)).maxX,
y: metrics.frame(in: .named(ReferencePointReader.referenceSpace)).midY
)
case .leadingTop:
CGPoint(
x: metrics.frame(in: .named(ReferencePointReader.referenceSpace)).minX,
y: metrics.frame(in: .named(ReferencePointReader.referenceSpace)).minY
)
}
}()
Rectangle()
.fill(Color.clear)
.preference(
key: PositionKey.self,
value: [PositionValue(id: id, referencePosition: position)]
)
}
}
}
struct PositionValue: Equatable {
typealias ID = String
let id: ID
let referencePosition: CGPoint
}
struct PositionKey: PreferenceKey {
static var defaultValue: [PositionValue] = []
static func reduce(value: inout [PositionValue], nextValue: () -> [PositionValue]) {
value.append(contentsOf: nextValue())
}
}
extension Array<PositionValue> {
func deltaFrom(_ startKey: PositionValue.ID, to endKey: PositionValue.ID) -> CGPoint? {
var startPoint: CGPoint?
var endPoint: CGPoint?
for pref in self {
if pref.id == startKey {
startPoint = pref.referencePosition
} else if pref.id == endKey {
endPoint = pref.referencePosition
}
}
guard let endPoint, let startPoint else { return nil }
let deltaX = endPoint.x - startPoint.x
let deltaY = endPoint.y - startPoint.y
return CGPoint(x: deltaX, y: deltaY)
}
}