Refactor ComposeView to use a single List for everything

This commit is contained in:
Shadowfacts 2022-11-07 22:55:39 -05:00
parent 5a5c67e445
commit 7600954f4b
10 changed files with 143 additions and 97 deletions

View File

@ -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 */,

View File

@ -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:

View File

@ -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, *)

View File

@ -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

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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)

View File

@ -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()

View File

@ -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 {

View File

@ -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),
])
}
}