WIP poll editing

This commit is contained in:
Shadowfacts 2025-01-30 10:02:13 -05:00
parent fbbcb0d07d
commit ac78fe2807
9 changed files with 284 additions and 31 deletions

View File

@ -90,10 +90,6 @@ class ToolbarController: ViewController {
cwButton
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
#if !targetEnvironment(macCatalyst) && !os(visionOS)
// the button has a bunch of extra space by default, but combined with what we add it's too much
.padding(.horizontal, -8)
#endif
.disabled(draft.editedStatusID != nil)
.disabled(composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
@ -101,8 +97,6 @@ class ToolbarController: ViewController {
localOnlyPicker
#if targetEnvironment(macCatalyst)
.padding(.leading, 4)
#elseif !os(visionOS)
.padding(.horizontal, -8)
#endif
.disabled(draft.editedStatusID != nil)
}

View File

@ -197,9 +197,9 @@ private class WrappedCollectionViewController: UIViewController {
cell.containingViewController = self
cell.setView(AttachmentCollectionViewCellView(attachment: attachment))
}
let addButtonCell = UICollectionView.CellRegistration<HostingCollectionViewCell, Bool> { [unowned self] cell, indexPath, item in
let addButtonCell = UICollectionView.CellRegistration<HostingCollectionViewCell, Void> { [unowned self] cell, indexPath, item in
cell.containingViewController = self
cell.setView(AddAttachmentButton(viewController: self, enabled: item))
cell.setView(AddAttachmentButton(viewController: self))
}
let collectionView = IntrinsicContentSizeCollectionView(frame: .zero, collectionViewLayout: layout)
self.view = collectionView
@ -208,7 +208,7 @@ private class WrappedCollectionViewController: UIViewController {
case .attachment(let attachment):
return collectionView.dequeueConfiguredReusableCell(using: attachmentCell, for: indexPath, item: attachment)
case .addButton:
return collectionView.dequeueConfiguredReusableCell(using: addButtonCell, for: indexPath, item: true)
return collectionView.dequeueConfiguredReusableCell(using: addButtonCell, for: indexPath, item: ())
}
}
dataSource.reorderingHandlers.canReorderItem = { item in
@ -423,7 +423,7 @@ private class HostingCollectionViewCell: UICollectionViewCell {
private struct AddAttachmentButton: View {
unowned let viewController: WrappedCollectionViewController
let enabled: Bool
@Environment(\.canAddAttachment) private var enabled
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
@ -462,6 +462,7 @@ private struct AddAttachmentButton: View {
}
}
.disabled(!enabled)
.animation(.linear(duration: 0.2), value: enabled)
}
private var iconName: String {

View File

@ -13,18 +13,11 @@ struct ComposeDraftView: View {
@ObservedObject var draft: Draft
@FocusState.Binding var focusedField: FocusableField?
@Environment(\.currentAccount) private var currentAccount
@EnvironmentObject private var controller: ComposeController
var body: some View {
HStack(alignment: .top, spacing: 8) {
// TODO: scroll effect?
AvatarImageView(
url: currentAccount?.avatar,
size: 50,
style: controller.config.avatarStyle,
fetchAvatar: controller.fetchAvatar
)
.accessibilityHidden(true)
AvatarView(account: currentAccount)
VStack(alignment: .leading, spacing: 4) {
if let currentAccount {
@ -35,12 +28,40 @@ struct ComposeDraftView: View {
DraftContentEditor(draft: draft, focusedField: $focusedField)
if let poll = draft.poll {
PollEditor(poll: poll)
.padding(.bottom, 4)
// So that during the appearance transition, it's behind the text view.
.zIndex(-1)
.transition(.move(edge: .top).combined(with: .opacity))
}
AttachmentsSection(draft: draft)
// We want the padding between the poll/attachments to be part of the poll, so it animates in/out with the transition.
// Otherwise, when the poll is added, its bottom edge is aligned with the top edge of the attachments
.padding(.top, draft.poll == nil ? 0 : -4)
}
.animation(.snappy, value: draft.poll == nil)
}
}
}
private struct AvatarView: View {
let account: (any AccountProtocol)?
@Environment(\.composeUIConfig.avatarStyle) private var avatarStyle
@EnvironmentObject private var controller: ComposeController
var body: some View {
AvatarImageView(
url: account?.avatar,
size: 50,
style: avatarStyle,
fetchAvatar: controller.fetchAvatar
)
.accessibilityHidden(true)
}
}
private struct AccountNameView: View {
let account: any AccountProtocol
@EnvironmentObject private var controller: ComposeController

View File

@ -142,10 +142,6 @@ private struct VisibilityButton: View {
var body: some View {
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
#if !targetEnvironment(macCatalyst) && !os(visionOS)
// the button has a bunch of extra space by default, but combined with what we add it's too much
.padding(.horizontal, -8)
#endif
.disabled(draft.editedStatusID != nil)
.disabled(instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
}

View File

@ -20,16 +20,13 @@ struct DraftContentEditor: View {
HStack(alignment: .firstTextBaseline) {
LanguageButton(draft: draft)
TogglePollButton(draft: draft)
Spacer()
CharactersRemaining(draft: draft)
.padding(.trailing, 4)
.padding(.trailing, 6)
}
.padding(.all.subtracting(.top), 2)
}
.background {
RoundedRectangle(cornerRadius: 5)
.fill(colorScheme == .dark ? fillColor : Color(uiColor: .secondarySystemBackground))
}
.composePlatterBackground()
}
private func addAttachments(_ providers: [NSItemProvider]) {
@ -50,7 +47,7 @@ private struct CharactersRemaining: View {
var body: some View {
Text(verbatim: charsRemaining.description)
.foregroundStyle(charsRemaining < 0 ? .red : .secondary)
.font(.callout.monospacedDigit())
.font(.body.monospacedDigit())
.accessibility(label: Text(charsRemaining < 0 ? "\(-charsRemaining) characters too many" : "\(charsRemaining) characters remaining"))
}
}
@ -85,11 +82,36 @@ private struct LanguageButton: View {
private struct LanguageButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.callout)
.foregroundStyle(.tint.opacity(configuration.isPressed ? 0.8 : 1))
.padding(.vertical, 2)
.padding(.horizontal, 4)
.background(.tint.opacity(configuration.isPressed ? 0.15 : 0.2), in: RoundedRectangle(cornerRadius: 3))
.animation(.linear(duration: 0.1), value: configuration.isPressed)
.padding(2)
}
}
private struct TogglePollButton: View {
@ObservedObject var draft: Draft
@EnvironmentObject private var instanceFeatures: InstanceFeatures
var body: some View {
Button {
if draft.poll == nil {
draft.poll = Poll(context: DraftsPersistentContainer.shared.viewContext)
} else {
draft.poll = nil
}
} label: {
Image(systemName: draft.poll == nil ? "chart.bar.doc.horizontal" : "chart.bar.horizontal.page.fill")
}
.buttonStyle(LanguageButtonStyle())
.disabled(disabled)
.animation(.linear(duration: 0.2), value: disabled)
.animation(.linear(duration: 0.2), value: draft.poll == nil)
}
private var disabled: Bool {
instanceFeatures.mastodonAttachmentRestrictions && draft.attachments.count > 0
}
}

View File

@ -0,0 +1,29 @@
//
// BackgroundPlatterView.swift
// ComposeUI
//
// Created by Shadowfacts on 1/29/25.
//
import SwiftUI
extension View {
func composePlatterBackground() -> some View {
self.background {
PlatterBackgroundView()
}
}
}
private struct PlatterBackgroundView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.composeUIConfig.fillColor) private var fillColor
var body: some View {
RoundedRectangle(cornerRadius: 5)
// TODO: fillColor is semi-transparent in pure-black dark mode, but it needs to be fully opaque for the poll transition to look right
// .fill(colorScheme == .dark ? fillColor : Color(uiColor: .secondarySystemBackground))
.fill(Color(uiColor: .secondarySystemBackground))
}
}

View File

@ -0,0 +1,189 @@
//
// PollEditor.swift
// ComposeUI
//
// Created by Shadowfacts on 1/29/25.
//
import SwiftUI
import InstanceFeatures
import TuskerComponents
struct PollEditor: View {
@ObservedObject var poll: Poll
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Poll")
.font(.headline)
ForEach(poll.pollOptions) { option in
PollOptionEditor(option: option, index: poll.options.index(of: option)) {
self.removeOption(option)
}
}
AddOptionButton(poll: poll)
Toggle("Multiple choice", isOn: $poll.multiple)
.padding(.bottom, -8)
HStack {
Text("Duration")
Spacer()
PollDurationPicker(poll: poll)
.frame(minHeight: 32)
}
}
.padding(.all.subtracting(.bottom), 8)
.padding(.bottom, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.composePlatterBackground()
}
private func removeOption(_ option: PollOption) {
let index = poll.options.index(of: option)
if index != NSNotFound {
var array = poll.options.array
array.remove(at: index)
poll.options = NSMutableOrderedSet(array: array)
}
}
}
private struct AddOptionButton: View {
@ObservedObject var poll: Poll
@EnvironmentObject private var instanceFeatures: InstanceFeatures
private var canAddOption: Bool {
if let max = instanceFeatures.maxPollOptionsCount {
poll.options.count < max
} else {
true
}
}
var body: some View {
Button(action: addOption) {
Label("Add Option", systemImage: "plus")
}
.buttonStyle(.borderless)
.disabled(!canAddOption)
}
private func addOption() {
let option = PollOption(context: DraftsPersistentContainer.shared.viewContext)
option.poll = poll
poll.options.add(option)
}
}
private struct PollDurationPicker: View {
@ObservedObject var poll: Poll
@State var duration: PollDuration
init(poll: Poll) {
self.poll = poll
self._duration = State(wrappedValue: .fromTimeInterval(poll.duration) ?? .oneDay)
}
private var options: [MenuPicker<PollDuration>.Option] {
PollDuration.allCases.map {
.init(value: $0, title: PollDuration.formatter.string(from: $0.timeInterval)!)
}
}
var body: some View {
MenuPicker(selection: $duration, options: options)
#if os(visionOS)
.onChange(of: duration) {
poll.duration = duration.timeInterval
}
#else
.onChange(of: duration) { newValue in
poll.duration = newValue.timeInterval
}
#endif
}
}
private enum PollDuration: Hashable, Equatable, CaseIterable {
case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays
static let formatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.maximumUnitCount = 1
f.unitsStyle = .full
f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
return f
}()
static func fromTimeInterval(_ ti: TimeInterval) -> PollDuration? {
for it in allCases where it.timeInterval == ti {
return it
}
return nil
}
var timeInterval: TimeInterval {
switch self {
case .fiveMinutes:
return 5 * 60
case .thirtyMinutes:
return 30 * 60
case .oneHour:
return 60 * 60
case .sixHours:
return 6 * 60 * 60
case .oneDay:
return 24 * 60 * 60
case .threeDays:
return 3 * 24 * 60 * 60
case .sevenDays:
return 7 * 24 * 60 * 60
}
}
}
private struct PollOptionEditor: View {
@ObservedObject var option: PollOption
let index: Int
let removeOption: () -> Void
@EnvironmentObject private var instanceFeatures: InstanceFeatures
var placeholder: String {
if index != NSNotFound {
"Option \(index + 1)"
} else {
""
}
}
var body: some View {
HStack(spacing: 4) {
Button(role: .destructive, action: removeOption) {
Label("Remove option", systemImage: "minus")
}
.labelStyle(.iconOnly)
.buttonStyle(PollOptionButtonStyle())
.accessibilityLabel("Remove option")
EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: instanceFeatures.maxPollOptionChars)
}
}
}
private struct PollOptionButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(.white)
.font(.body.bold())
.padding(4)
.frame(width: 20, height: 20)
.background {
let color = configuration.role == .destructive ? Color.red : .green
let opacity = configuration.isPressed ? 0.8 : 1
Circle()
.fill(color.opacity(opacity))
}
}
}

View File

@ -59,6 +59,7 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
#if targetEnvironment(macCatalyst)
config.macIdiomStyle = .bordered
#endif
config.contentInsets = .zero
return config
}

View File

@ -181,8 +181,8 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
private(set) lazy var contentContainer = StatusContentContainer(arrangedSubviews: [
contentTextView,
cardView,
attachmentsView,
pollView,
attachmentsView,
] as! [any StatusContentView], useTopSpacer: false).configure {
$0.setContentHuggingPriority(.defaultLow, for: .vertical)
}