forked from shadowfacts/Tusker
Refactor ComposeView to use a single List for everything
This commit is contained in:
parent
5a5c67e445
commit
7600954f4b
|
@ -152,6 +152,7 @@
|
|||
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; };
|
||||
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; };
|
||||
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; };
|
||||
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */; };
|
||||
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; };
|
||||
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; };
|
||||
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; };
|
||||
|
@ -507,6 +508,7 @@
|
|||
D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = "<group>"; };
|
||||
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; };
|
||||
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = "<group>"; };
|
||||
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPicker.swift; sourceTree = "<group>"; };
|
||||
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pachyderm; sourceTree = "<group>"; };
|
||||
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
|
||||
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; };
|
||||
|
@ -1289,6 +1291,7 @@
|
|||
D620483323D3801D008A63EF /* LinkTextView.swift */,
|
||||
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */,
|
||||
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */,
|
||||
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */,
|
||||
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
|
||||
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
|
||||
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
|
||||
|
@ -1853,6 +1856,7 @@
|
|||
D627FF7B217E951500CC0648 /* DraftsTableViewController.swift in Sources */,
|
||||
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */,
|
||||
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
|
||||
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */,
|
||||
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
|
||||
D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */,
|
||||
D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */,
|
||||
|
|
|
@ -14,7 +14,6 @@ import Vision
|
|||
struct ComposeAttachmentRow: View {
|
||||
@ObservedObject var draft: Draft
|
||||
@ObservedObject var attachment: CompositionAttachment
|
||||
let heightChanged: (CGFloat) -> Void
|
||||
|
||||
@EnvironmentObject var uiState: ComposeUIState
|
||||
@State private var mode: Mode = .allowEntry
|
||||
|
@ -47,7 +46,6 @@ struct ComposeAttachmentRow: View {
|
|||
switch mode {
|
||||
case .allowEntry:
|
||||
ComposeTextView(text: $attachment.attachmentDescription, placeholder: Text("Describe for the visually impaired…"), minHeight: 80)
|
||||
.heightDidChange(self.heightChanged)
|
||||
.backgroundColor(.clear)
|
||||
|
||||
case .recognizingText:
|
||||
|
|
|
@ -18,23 +18,17 @@ struct ComposeAttachmentsList: View {
|
|||
@EnvironmentObject var uiState: ComposeUIState
|
||||
@State var isShowingAssetPickerPopover = false
|
||||
@State var isShowingCreateDrawing = false
|
||||
@State var rowHeights = [UUID: CGFloat]()
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Group {
|
||||
ForEach(draft.attachments) { (attachment) in
|
||||
ComposeAttachmentRow(
|
||||
draft: draft,
|
||||
attachment: attachment
|
||||
) { (newHeight) in
|
||||
// in case height changed callback is called after atachment is removed but before view hierarchy is updated
|
||||
if draft.attachments.contains(where: { $0.id == attachment.id }) {
|
||||
rowHeights[attachment.id] = newHeight
|
||||
}
|
||||
}
|
||||
)
|
||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||
.onDrag { NSItemProvider(object: attachment) }
|
||||
}
|
||||
|
@ -69,12 +63,7 @@ struct ComposeAttachmentsList: View {
|
|||
.frame(height: cellHeight / 2)
|
||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||
}
|
||||
.listStyle(PlainListStyle())
|
||||
// todo: scrollDisabled doesn't remove the need for manually calculating the frame height
|
||||
.frame(height: totalListHeight)
|
||||
.scrollDisabledIfAvailable(totalHeight: totalListHeight)
|
||||
.onAppear(perform: self.didAppear)
|
||||
.onReceive(draft.$attachments, perform: self.attachmentsChanged)
|
||||
}
|
||||
|
||||
private var addButtonImageName: String {
|
||||
|
@ -104,13 +93,6 @@ struct ComposeAttachmentsList: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var totalListHeight: CGFloat {
|
||||
let totalRowHeights = rowHeights.values.reduce(0, +)
|
||||
let totalPadding = CGFloat(draft.attachments.count) * cellPadding
|
||||
let addButtonHeight = 3 * (cellHeight / 2 + cellPadding)
|
||||
return totalRowHeights + totalPadding + addButtonHeight
|
||||
}
|
||||
|
||||
private func didAppear() {
|
||||
if #available(iOS 16.0, *) {
|
||||
// these appearance proxy hacks are no longer necessary
|
||||
|
@ -122,17 +104,6 @@ struct ComposeAttachmentsList: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func attachmentsChanged(attachments: [CompositionAttachment]) {
|
||||
var copy = rowHeights
|
||||
for k in copy.keys where !attachments.contains(where: { k == $0.id }) {
|
||||
copy.removeValue(forKey: k)
|
||||
}
|
||||
for attachment in attachments where !copy.keys.contains(attachment.id) {
|
||||
copy[attachment.id] = cellHeight
|
||||
}
|
||||
self.rowHeights = copy
|
||||
}
|
||||
|
||||
private func assetPickerPopover() -> some View {
|
||||
ComposeAssetPicker(draft: draft, delegate: uiState.delegate?.assetPickerDelegate)
|
||||
.onDisappear {
|
||||
|
@ -214,16 +185,6 @@ fileprivate extension View {
|
|||
self
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
@ViewBuilder
|
||||
func scrollDisabledIfAvailable(totalHeight: CGFloat) -> some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
self.scrollDisabled(true)
|
||||
} else {
|
||||
self.frame(height: totalHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
|
|
|
@ -428,6 +428,10 @@ extension ComposeHostingController: UIAdaptivePresentationControllerDelegate {
|
|||
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
|
||||
return Preferences.shared.automaticallySaveDrafts || !draft.hasContent
|
||||
}
|
||||
|
||||
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: self, for: nil)
|
||||
}
|
||||
|
||||
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
|
||||
uiState.isShowingSaveDraftSheet = true
|
||||
|
|
|
@ -44,6 +44,7 @@ struct ComposePollView: View {
|
|||
.imageScale(.small)
|
||||
.padding(4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accentColor(buttonForegroundColor)
|
||||
.background(Circle().foregroundColor(buttonBackgroundColor))
|
||||
.hoverEffect()
|
||||
|
@ -52,31 +53,22 @@ struct ComposePollView: View {
|
|||
ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in
|
||||
ComposePollOption(poll: poll, option: e.element, optionIndex: e.offset)
|
||||
}
|
||||
.transition(.slide)
|
||||
|
||||
Button(action: self.addOption) {
|
||||
Label("Add Option", systemImage: "plus")
|
||||
}
|
||||
|
||||
.buttonStyle(.borderless)
|
||||
|
||||
HStack {
|
||||
// use .animation(nil) on pickers so frame doesn't have a size change animation when the text changes
|
||||
// this is deprecated in iOS 15, but using .animation(nil, value: poll.multiple) does not work (it still animates)
|
||||
// nor does setting that on the Text rather than the Picker
|
||||
Picker(selection: $poll.multiple, label: Text(poll.multiple ? "Allow multiple choice" : "Single choice")) {
|
||||
Text("Allow multiple choices").tag(true)
|
||||
Text("Single choice").tag(false)
|
||||
}
|
||||
.animation(nil)
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
MenuPicker(selection: $poll.multiple, options: [
|
||||
.init(title: "Allow multiple", value: true),
|
||||
.init(title: "Single choice", value: false),
|
||||
])
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Picker(selection: $duration, label: Text(verbatim: ComposePollView.formatter.string(from: duration.timeInterval)!)) {
|
||||
ForEach(Duration.allCases, id: \.self) { (duration) in
|
||||
Text(ComposePollView.formatter.string(from: duration.timeInterval)!).tag(duration)
|
||||
}
|
||||
}
|
||||
.animation(nil)
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
MenuPicker(selection: $duration, options: Duration.allCases.map {
|
||||
.init(title: ComposePollView.formatter.string(from: $0.timeInterval)!, value: $0)
|
||||
})
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
@ -110,9 +102,7 @@ struct ComposePollView: View {
|
|||
}
|
||||
|
||||
private func addOption() {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
poll.options.append(Draft.Poll.Option(""))
|
||||
}
|
||||
poll.options.append(Draft.Poll.Option(""))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -163,6 +153,7 @@ struct ComposePollOption: View {
|
|||
Button(action: self.removeOption) {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(poll.options.count == 1 ? .gray : .red)
|
||||
.disabled(poll.options.count == 1)
|
||||
.hoverEffect()
|
||||
|
@ -175,9 +166,7 @@ struct ComposePollOption: View {
|
|||
}
|
||||
|
||||
private func removeOption() {
|
||||
_ = withAnimation {
|
||||
poll.options.remove(at: optionIndex)
|
||||
}
|
||||
poll.options.remove(at: optionIndex)
|
||||
}
|
||||
|
||||
struct Checkbox: View {
|
||||
|
|
|
@ -17,18 +17,20 @@ struct ComposeReplyContentView: UIViewRepresentable {
|
|||
|
||||
let heightChanged: (CGFloat) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> ComposeReplyContentTextView {
|
||||
func makeUIView(context: Context) -> UIViewType {
|
||||
let view = ComposeReplyContentTextView()
|
||||
view.overrideMastodonController = mastodonController
|
||||
view.setTextFrom(status: status)
|
||||
view.isUserInteractionEnabled = false
|
||||
// scroll needs to be enabled, otherwise the text view never reports a contentSize greater than 1 line
|
||||
view.isScrollEnabled = true
|
||||
view.backgroundColor = .clear
|
||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: ComposeReplyContentTextView, context: Context) {
|
||||
func updateUIView(_ uiView: UIViewType, context: Context) {
|
||||
uiView.heightChanged = heightChanged
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,8 @@ import SwiftUI
|
|||
|
||||
struct ComposeReplyView: View {
|
||||
let status: StatusMO
|
||||
let stackPadding: CGFloat
|
||||
let rowTopInset: CGFloat
|
||||
let globalFrameOutsideList: CGRect
|
||||
|
||||
@State private var displayNameHeight: CGFloat?
|
||||
@State private var contentHeight: CGFloat?
|
||||
|
@ -44,9 +45,13 @@ struct ComposeReplyView: View {
|
|||
displayNameHeight = newValue
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
ComposeReplyContentView(status: status) { newHeight in
|
||||
contentHeight = newHeight
|
||||
// otherwise, with long in-reply-to statuses, the main content text view position seems not to update
|
||||
// and it ends up partially behind the header
|
||||
DispatchQueue.main.async {
|
||||
contentHeight = newHeight
|
||||
}
|
||||
}
|
||||
.frame(height: contentHeight ?? 0)
|
||||
}
|
||||
|
@ -55,20 +60,22 @@ struct ComposeReplyView: View {
|
|||
}
|
||||
|
||||
private func replyAvatarImage(geometry: GeometryProxy) -> some View {
|
||||
let scrollOffset = -geometry.frame(in: .named(ComposeView.coordinateSpaceOutsideOfScrollView)).minY
|
||||
// using a coordinate space declared outside of the List doesn't work, so we do the math ourselves
|
||||
let globalFrame = geometry.frame(in: .global)
|
||||
let scrollOffset = -(globalFrame.minY - globalFrameOutsideList.minY)
|
||||
|
||||
// add rowTopInset so that the image is always at least rowTopInset away from the top
|
||||
var offset = scrollOffset + rowTopInset
|
||||
|
||||
// add stackPadding so that the image is always at least stackPadding away from the top
|
||||
var offset = scrollOffset + stackPadding
|
||||
|
||||
// offset can never be less than 0 (i.e., above the top of the in-reply-to content)
|
||||
offset = max(offset, 0)
|
||||
|
||||
// subtract 50, because we care about where the bottom of the view is but the offset is relative to the top of the view
|
||||
let maxOffset = max((contentHeight ?? 0) + (displayNameHeight ?? 0) - 50, 0)
|
||||
|
||||
|
||||
// once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content
|
||||
offset = min(offset, maxOffset)
|
||||
|
||||
|
||||
return ComposeAvatarImageView(url: status.account.avatar)
|
||||
.frame(width: 50, height: 50)
|
||||
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
|
||||
|
|
|
@ -42,12 +42,12 @@ import Combine
|
|||
}
|
||||
|
||||
struct ComposeView: View {
|
||||
static let coordinateSpaceOutsideOfScrollView = "coordinateSpaceOutsideOfScrollView"
|
||||
|
||||
@ObservedObject var draft: Draft
|
||||
@EnvironmentObject var mastodonController: MastodonController
|
||||
@EnvironmentObject var uiState: ComposeUIState
|
||||
|
||||
@State private var globalFrameOutsideList: CGRect = .zero
|
||||
|
||||
@OptionalStateObject private var poster: PostService?
|
||||
@State private var isShowingPostErrorAlert = false
|
||||
@State private var postError: PostService.Error?
|
||||
|
@ -80,11 +80,8 @@ struct ComposeView: View {
|
|||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
ScrollView(.vertical) {
|
||||
mainStack
|
||||
}
|
||||
.coordinateSpace(name: ComposeView.coordinateSpaceOutsideOfScrollView)
|
||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||
mainList
|
||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||
|
||||
if let poster = poster {
|
||||
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
|
||||
|
@ -93,6 +90,13 @@ struct ComposeView: View {
|
|||
|
||||
autocompleteSuggestions
|
||||
}
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: GlobalFrameOutsideListPrefKey.self, value: proxy.frame(in: .global))
|
||||
.onPreferenceChange(GlobalFrameOutsideListPrefKey.self) { frame in
|
||||
globalFrameOutsideList = frame
|
||||
}
|
||||
})
|
||||
.navigationBarTitle("Compose")
|
||||
.actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet)
|
||||
.alert(isPresented: $isShowingPostErrorAlert) {
|
||||
|
@ -120,41 +124,49 @@ struct ComposeView: View {
|
|||
.animation(.default, value: uiState.autocompleteState)
|
||||
}
|
||||
|
||||
var mainStack: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
var mainList: some View {
|
||||
List {
|
||||
if let id = draft.inReplyToID,
|
||||
let status = mastodonController.persistentContainer.status(for: id) {
|
||||
ComposeReplyView(
|
||||
status: status,
|
||||
stackPadding: stackPadding
|
||||
rowTopInset: 8,
|
||||
globalFrameOutsideList: globalFrameOutsideList
|
||||
)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
|
||||
header
|
||||
.listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
if draft.contentWarningEnabled {
|
||||
ComposeEmojiTextField(text: $draft.contentWarning, placeholder: "Write your warning here")
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
|
||||
MainComposeTextView(
|
||||
draft: draft
|
||||
)
|
||||
MainComposeTextView(draft: draft)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
if let poll = draft.poll {
|
||||
ComposePollView(draft: draft, poll: poll)
|
||||
.transition(.opacity.combined(with: .asymmetric(insertion: .scale(scale: 0.5, anchor: .leading), removal: .scale(scale: 0.5, anchor: .trailing))))
|
||||
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
|
||||
ComposeAttachmentsList(
|
||||
draft: draft
|
||||
)
|
||||
// the list rows provide their own padding, so we cancel out the extra spacing from the VStack
|
||||
.padding([.top, .bottom], -8)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
|
||||
}
|
||||
.animation(.default, value: draft.poll?.options.count)
|
||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||
.listStyle(.plain)
|
||||
.disabled(isPosting)
|
||||
.padding(stackPadding)
|
||||
.padding(.bottom, uiState.autocompleteState != nil ? 46 : nil)
|
||||
.padding(.bottom, uiState.autocompleteState != nil ? 46 : 0)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
|
@ -258,6 +270,13 @@ private extension View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct GlobalFrameOutsideListPrefKey: PreferenceKey {
|
||||
static var defaultValue: CGRect = .zero
|
||||
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
//struct ComposeView_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// ComposeView()
|
||||
|
|
|
@ -17,6 +17,10 @@ struct MainComposeTextView: View {
|
|||
if Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
|
||||
return Text("Happy π day!")
|
||||
}
|
||||
} else if components.month == 9 && components.day == 5 {
|
||||
// https://weirder.earth/@noracodes/109276419847254552
|
||||
// https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
|
||||
return Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic()
|
||||
} else if components.month == 9 && components.day == 21 {
|
||||
return Text("Do you remember?")
|
||||
} else if components.month == 10 && components.day == 31 {
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
//
|
||||
// MenuPicker.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 11/7/22.
|
||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MenuPicker<Value: Hashable>: View {
|
||||
@Binding var selection: Value
|
||||
let options: [Option]
|
||||
|
||||
private var selectedOption: Option {
|
||||
options.first(where: { $0.value == selection })!
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
ForEach(options, id: \.value) { option in
|
||||
Button {
|
||||
selection = option.value
|
||||
} label: {
|
||||
Label(option.title, systemImage: selection == option.value ? "checkmark" : "")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
// zstack so that the size of the picker is the size of the largest option
|
||||
ZStack {
|
||||
ForEach(options, id: \.value) { option in
|
||||
HStack {
|
||||
Text(option.title)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
}
|
||||
.opacity(option.value == selection ? 1 : 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
}
|
||||
|
||||
struct Option {
|
||||
let title: String
|
||||
let value: Value
|
||||
}
|
||||
}
|
||||
|
||||
struct MenuPicker_Previews: PreviewProvider {
|
||||
@State static var value = 0
|
||||
static var previews: some View {
|
||||
MenuPicker(selection: $value, options: [
|
||||
.init(title: "Zero", value: 0),
|
||||
.init(title: "One", value: 1),
|
||||
.init(title: "Two", value: 2),
|
||||
])
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue