More compose screen tweaks

This commit is contained in:
Shadowfacts 2025-01-30 21:57:23 -05:00
parent f46150422a
commit ec3678d90d
8 changed files with 101 additions and 62 deletions

View File

@ -72,7 +72,7 @@ private struct PostOrDraftsButton: View {
} }
private var draftIsEmpty: Bool { private var draftIsEmpty: Bool {
draft.text == draft.initialText && (!draft.contentWarningEnabled || draft.contentWarning == draft.initialContentWarning) && draft.attachments.count == 0 && !draft.pollEnabled draft.text == draft.initialText && (!draft.contentWarningEnabled || draft.contentWarning == draft.initialContentWarning) && draft.attachments.count == 0 && (!draft.pollEnabled || !draft.poll!.hasContent)
} }
} }
#endif #endif

View File

@ -107,8 +107,12 @@ private struct ContentWarningButton: View {
private func toggleContentWarning() { private func toggleContentWarning() {
enabled.toggle() enabled.toggle()
if focusedField != nil {
if enabled { if enabled {
focusedField = .contentWarning focusedField = .contentWarning
} else if focusedField == .contentWarning {
focusedField = .body
}
} }
} }
} }

View File

@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import CoreData
struct ComposeView: View { struct ComposeView: View {
@ObservedObject var draft: Draft @ObservedObject var draft: Draft
@ -103,7 +104,7 @@ struct ComposeView: View {
VStack(spacing: 8) { VStack(spacing: 8) {
NewReplyStatusView(draft: draft, mastodonController: mastodonController) NewReplyStatusView(draft: draft, mastodonController: mastodonController)
ComposeDraftView(draft: draft, focusedField: $focusedField) DraftEditor(draft: draft, focusedField: $focusedField)
} }
.padding(8) .padding(8)
} }
@ -151,16 +152,7 @@ public struct NavigationTitlePreferenceKey: PreferenceKey {
enum FocusableField: Hashable { enum FocusableField: Hashable {
case contentWarning case contentWarning
case body case body
case attachmentDescription(UUID) case pollOption(NSManagedObjectID)
var nextField: FocusableField? {
switch self {
case .contentWarning:
return .body
default:
return nil
}
}
} }
#if !os(visionOS) && !targetEnvironment(macCatalyst) #if !os(visionOS) && !targetEnvironment(macCatalyst)

View File

@ -12,7 +12,6 @@ struct ContentWarningTextField: View {
@FocusState.Binding var focusedField: FocusableField? @FocusState.Binding var focusedField: FocusableField?
var body: some View { var body: some View {
if draft.contentWarningEnabled {
EmojiTextField( EmojiTextField(
text: $draft.contentWarning, text: $draft.contentWarning,
placeholder: "Write your warning here", placeholder: "Write your warning here",
@ -30,7 +29,6 @@ struct ContentWarningTextField: View {
.focused($focusedField, equals: .contentWarning) .focused($focusedField, equals: .contentWarning)
.modifier(FocusedInputModifier()) .modifier(FocusedInputModifier())
} }
}
} }
//#Preview { //#Preview {

View File

@ -20,7 +20,7 @@ struct DraftContentEditor: View {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
LanguageButton(draft: draft) LanguageButton(draft: draft)
TogglePollButton(draft: draft) TogglePollButton(draft: draft, focusedField: $focusedField)
Spacer() Spacer()
CharactersRemaining(draft: draft) CharactersRemaining(draft: draft)
.padding(.trailing, 6) .padding(.trailing, 6)
@ -82,6 +82,7 @@ private struct LanguageButton: View {
private struct LanguageButtonStyle: ButtonStyle { private struct LanguageButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
configuration.label configuration.label
.font(.body.monospaced())
.foregroundStyle(.tint.opacity(configuration.isPressed ? 0.8 : 1)) .foregroundStyle(.tint.opacity(configuration.isPressed ? 0.8 : 1))
.padding(.vertical, 2) .padding(.vertical, 2)
.padding(.horizontal, 4) .padding(.horizontal, 4)
@ -93,6 +94,7 @@ private struct LanguageButtonStyle: ButtonStyle {
private struct TogglePollButton: View { private struct TogglePollButton: View {
@ObservedObject var draft: Draft @ObservedObject var draft: Draft
@FocusState.Binding var focusedField: FocusableField?
@EnvironmentObject private var instanceFeatures: InstanceFeatures @EnvironmentObject private var instanceFeatures: InstanceFeatures
var body: some View { var body: some View {
@ -112,11 +114,24 @@ private struct TogglePollButton: View {
private func togglePoll() { private func togglePoll() {
if draft.pollEnabled { if draft.pollEnabled {
draft.pollEnabled = false draft.pollEnabled = false
if case .pollOption(_) = focusedField {
focusedField = .body
}
} else { } else {
if draft.poll == nil { let poll: Poll
draft.poll = Poll(context: DraftsPersistentContainer.shared.viewContext) if let p = draft.poll {
poll = p
} else {
poll = Poll(context: DraftsPersistentContainer.shared.viewContext)
draft.poll = poll
} }
draft.pollEnabled = true draft.pollEnabled = true
if focusedField != nil {
let optionToFocus = poll.pollOptions.first(where: { $0.text.isEmpty }) ?? poll.pollOptions.last!
focusedField = .pollOption(optionToFocus.id)
}
} }
} }
} }

View File

@ -1,5 +1,5 @@
// //
// ComposeDraftView.swift // DraftEditor.swift
// ComposeUI // ComposeUI
// //
// Created by Shadowfacts on 11/16/24. // Created by Shadowfacts on 11/16/24.
@ -9,7 +9,7 @@ import SwiftUI
import Pachyderm import Pachyderm
import TuskerComponents import TuskerComponents
struct ComposeDraftView: View { struct DraftEditor: View {
@ObservedObject var draft: Draft @ObservedObject var draft: Draft
@FocusState.Binding var focusedField: FocusableField? @FocusState.Binding var focusedField: FocusableField?
@Environment(\.currentAccount) private var currentAccount @Environment(\.currentAccount) private var currentAccount
@ -24,13 +24,16 @@ struct ComposeDraftView: View {
AccountNameView(account: currentAccount) AccountNameView(account: currentAccount)
} }
if draft.contentWarningEnabled {
ContentWarningTextField(draft: draft, focusedField: $focusedField) ContentWarningTextField(draft: draft, focusedField: $focusedField)
.transition(.opacity)
}
DraftContentEditor(draft: draft, focusedField: $focusedField) DraftContentEditor(draft: draft, focusedField: $focusedField)
if let poll = draft.poll, if let poll = draft.poll,
draft.pollEnabled { draft.pollEnabled {
PollEditor(poll: poll) PollEditor(poll: poll, focusedField: $focusedField)
.padding(.bottom, 4) .padding(.bottom, 4)
// So that during the appearance transition, it's behind the text view. // So that during the appearance transition, it's behind the text view.
.zIndex(-1) .zIndex(-1)
@ -40,11 +43,12 @@ struct ComposeDraftView: View {
AttachmentsSection(draft: draft) 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. // 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 // 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) .padding(.top, draft.pollEnabled ? -4 : 0)
} }
// These animations are here, because the height of the VStack and the positions of the lower views needs to animate too. // These animations are here, because the height of the VStack and the positions of the lower views needs to animate too.
.animation(.snappy, value: draft.pollEnabled) .animation(.snappy, value: draft.pollEnabled)
.modifier(PollAnimatingModifier(poll: draft.poll)) .modifier(PollAnimatingModifier(poll: draft.poll))
.animation(.snappy, value: draft.contentWarningEnabled)
} }
} }
} }

View File

@ -142,6 +142,10 @@ private final class WrappedTextViewCoordinator: NSObject {
private func attributedTextFromPlain(_ text: String) -> NSAttributedString { private func attributedTextFromPlain(_ text: String) -> NSAttributedString {
let str = NSMutableAttributedString(string: text) let str = NSMutableAttributedString(string: text)
str.addAttributes([
.foregroundColor: UIColor.label,
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
], range: NSRange(location: 0, length: str.length))
let mentionMatches = CharacterCounter.mention.matches(in: text, range: NSRange(location: 0, length: str.length)) let mentionMatches = CharacterCounter.mention.matches(in: text, range: NSRange(location: 0, length: str.length))
for match in mentionMatches.reversed() { for match in mentionMatches.reversed() {
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location) str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
@ -175,7 +179,7 @@ private final class WrappedTextViewCoordinator: NSObject {
if mentionRegex.numberOfMatches(in: substr, range: NSRange(location: 0, length: substr.utf16.count)) == 0 { if mentionRegex.numberOfMatches(in: substr, range: NSRange(location: 0, length: substr.utf16.count)) == 0 {
changed = true changed = true
str.removeAttribute(.mention, range: range) str.removeAttribute(.mention, range: range)
str.removeAttribute(.foregroundColor, range: range) str.addAttribute(.foregroundColor, value: UIColor.label, range: range)
if hasTextAttachment { if hasTextAttachment {
str.deleteCharacters(in: NSRange(location: range.location, length: 1)) str.deleteCharacters(in: NSRange(location: range.location, length: 1))
cursorOffset -= 1 cursorOffset -= 1

View File

@ -11,6 +11,7 @@ import TuskerComponents
struct PollEditor: View { struct PollEditor: View {
@ObservedObject var poll: Poll @ObservedObject var poll: Poll
@FocusState.Binding var focusedField: FocusableField?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@ -18,13 +19,11 @@ struct PollEditor: View {
.font(.headline) .font(.headline)
ForEach(poll.pollOptions) { option in ForEach(poll.pollOptions) { option in
PollOptionEditor(option: option, index: poll.options.index(of: option)) { PollOptionEditor(poll: poll, option: option, focusedField: $focusedField)
self.removeOption(option)
}
.transition(.opacity) .transition(.opacity)
} }
AddOptionButton(poll: poll) AddOptionButton(poll: poll, focusedField: $focusedField)
Toggle("Multiple choice", isOn: $poll.multiple) Toggle("Multiple choice", isOn: $poll.multiple)
.padding(.bottom, -8) .padding(.bottom, -8)
@ -41,19 +40,11 @@ struct PollEditor: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.composePlatterBackground() .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 { private struct AddOptionButton: View {
@ObservedObject var poll: Poll @ObservedObject var poll: Poll
@FocusState.Binding var focusedField: FocusableField?
@EnvironmentObject private var instanceFeatures: InstanceFeatures @EnvironmentObject private var instanceFeatures: InstanceFeatures
private var canAddOption: Bool { private var canAddOption: Bool {
@ -76,6 +67,7 @@ private struct AddOptionButton: View {
let option = PollOption(context: DraftsPersistentContainer.shared.viewContext) let option = PollOption(context: DraftsPersistentContainer.shared.viewContext)
option.poll = poll option.poll = poll
poll.options.add(option) poll.options.add(option)
focusedField = .pollOption(option.id)
} }
} }
@ -147,16 +139,17 @@ private enum PollDuration: Hashable, Equatable, CaseIterable {
} }
private struct PollOptionEditor: View { private struct PollOptionEditor: View {
@ObservedObject var poll: Poll
@ObservedObject var option: PollOption @ObservedObject var option: PollOption
let index: Int @FocusState.Binding var focusedField: FocusableField?
let removeOption: () -> Void
@EnvironmentObject private var instanceFeatures: InstanceFeatures @EnvironmentObject private var instanceFeatures: InstanceFeatures
var placeholder: String { var placeholder: String {
let index = poll.options.index(of: option)
if index != NSNotFound { if index != NSNotFound {
"Option \(index + 1)" return "Option \(index + 1)"
} else { } else {
"" return ""
} }
} }
@ -168,12 +161,33 @@ private struct PollOptionEditor: View {
.labelStyle(.iconOnly) .labelStyle(.iconOnly)
.buttonStyle(PollOptionButtonStyle()) .buttonStyle(PollOptionButtonStyle())
.accessibilityLabel("Remove option") .accessibilityLabel("Remove option")
.disabled(poll.options.count == 1)
EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: instanceFeatures.maxPollOptionChars) EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: instanceFeatures.maxPollOptionChars)
.focused($focusedField, equals: .pollOption(option.id))
}
}
private func removeOption() {
let index = poll.options.index(of: option)
if index != NSNotFound && poll.options.count > 1 {
var array = poll.options.array
array.remove(at: index)
poll.options = NSMutableOrderedSet(array: array)
// TODO: does this leave dangling PollOptions in the managed object context?
if case .pollOption(let id) = focusedField,
id == option.id {
let indexToFocus = index > 0 ? index - 1 : 0
focusedField = .pollOption(poll.pollOptions[indexToFocus].id)
}
} }
} }
} }
private struct PollOptionButtonStyle: ButtonStyle { private struct PollOptionButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
configuration.label configuration.label
.foregroundStyle(.white) .foregroundStyle(.white)
@ -181,7 +195,15 @@ private struct PollOptionButtonStyle: ButtonStyle {
.padding(4) .padding(4)
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
.background { .background {
let color = configuration.role == .destructive ? Color.red : .green let color = if isEnabled {
if configuration.role == .destructive {
Color.red
} else {
Color.green
}
} else {
Color.gray
}
let opacity = configuration.isPressed ? 0.8 : 1 let opacity = configuration.isPressed ? 0.8 : 1
Circle() Circle()
.fill(color.opacity(opacity)) .fill(color.opacity(opacity))