From 7600954f4b63e67d5160a70d4fa5c4a4c352ac61 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 7 Nov 2022 22:55:39 -0500 Subject: [PATCH] Refactor ComposeView to use a single List for everything --- Tusker.xcodeproj/project.pbxproj | 4 ++ .../Compose/ComposeAttachmentRow.swift | 2 - .../Compose/ComposeAttachmentsList.swift | 43 +------------- .../Compose/ComposeHostingController.swift | 4 ++ Tusker/Screens/Compose/ComposePollView.swift | 37 +++++------- .../Compose/ComposeReplyContentView.swift | 6 +- Tusker/Screens/Compose/ComposeReplyView.swift | 25 +++++--- Tusker/Screens/Compose/ComposeView.swift | 57 ++++++++++++------ .../Screens/Compose/MainComposeTextView.swift | 4 ++ Tusker/Views/MenuPicker.swift | 58 +++++++++++++++++++ 10 files changed, 143 insertions(+), 97 deletions(-) create mode 100644 Tusker/Views/MenuPicker.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 3f370230..504e44c0 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -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 = ""; }; D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = ""; }; D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = ""; }; + D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPicker.swift; sourceTree = ""; }; D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pachyderm; sourceTree = ""; }; D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Tusker/Screens/Compose/ComposeAttachmentRow.swift b/Tusker/Screens/Compose/ComposeAttachmentRow.swift index ee804578..b08e971c 100644 --- a/Tusker/Screens/Compose/ComposeAttachmentRow.swift +++ b/Tusker/Screens/Compose/ComposeAttachmentRow.swift @@ -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: diff --git a/Tusker/Screens/Compose/ComposeAttachmentsList.swift b/Tusker/Screens/Compose/ComposeAttachmentsList.swift index 373d71f8..669ad9fc 100644 --- a/Tusker/Screens/Compose/ComposeAttachmentsList.swift +++ b/Tusker/Screens/Compose/ComposeAttachmentsList.swift @@ -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, *) diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index 3c36a15a..4bb5ff2d 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -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 diff --git a/Tusker/Screens/Compose/ComposePollView.swift b/Tusker/Screens/Compose/ComposePollView.swift index 6c1d0d46..4b66808f 100644 --- a/Tusker/Screens/Compose/ComposePollView.swift +++ b/Tusker/Screens/Compose/ComposePollView.swift @@ -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 { diff --git a/Tusker/Screens/Compose/ComposeReplyContentView.swift b/Tusker/Screens/Compose/ComposeReplyContentView.swift index 28df101e..19b47daa 100644 --- a/Tusker/Screens/Compose/ComposeReplyContentView.swift +++ b/Tusker/Screens/Compose/ComposeReplyContentView.swift @@ -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 } } diff --git a/Tusker/Screens/Compose/ComposeReplyView.swift b/Tusker/Screens/Compose/ComposeReplyView.swift index 000e8b29..8f380359 100644 --- a/Tusker/Screens/Compose/ComposeReplyView.swift +++ b/Tusker/Screens/Compose/ComposeReplyView.swift @@ -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) diff --git a/Tusker/Screens/Compose/ComposeView.swift b/Tusker/Screens/Compose/ComposeView.swift index 5cfab82f..dacd663a 100644 --- a/Tusker/Screens/Compose/ComposeView.swift +++ b/Tusker/Screens/Compose/ComposeView.swift @@ -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() diff --git a/Tusker/Screens/Compose/MainComposeTextView.swift b/Tusker/Screens/Compose/MainComposeTextView.swift index 5302ebfc..40ace2ba 100644 --- a/Tusker/Screens/Compose/MainComposeTextView.swift +++ b/Tusker/Screens/Compose/MainComposeTextView.swift @@ -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 { diff --git a/Tusker/Views/MenuPicker.swift b/Tusker/Views/MenuPicker.swift new file mode 100644 index 00000000..b6eaf8d7 --- /dev/null +++ b/Tusker/Views/MenuPicker.swift @@ -0,0 +1,58 @@ +// +// MenuPicker.swift +// Tusker +// +// Created by Shadowfacts on 11/7/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import SwiftUI + +struct MenuPicker: 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), + ]) + } +}