From 891fd3826b5be5a4a1b6980cc18f0f636ea105ef Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 29 Apr 2023 18:49:02 -0400 Subject: [PATCH] Add expanded attachment description view to Compose screen Closes #365 --- Packages/ComposeUI/Package.swift | 3 +- .../Controllers/AttachmentRowController.swift | 53 +++- .../AttachmentsListController.swift | 1 + .../Controllers/ComposeController.swift | 27 +- .../Controllers/DraftsController.swift | 5 +- .../FocusedAttachmentController.swift | 95 +++++++ .../Sources/ComposeUI/KeyboardReader.swift | 18 +- .../Views/AttachmentDescriptionTextView.swift | 69 ++++-- .../Views/AttachmentThumbnailView.swift | 114 +++++++++ .../MatchedGeometryPresentation/.gitignore | 9 + .../MatchedGeometryPresentation/Package.swift | 26 ++ .../MatchedGeometryModifiers.swift | 125 ++++++++++ .../MatchedGeometryViewController.swift | 234 ++++++++++++++++++ .../View+PresentViewController.swift | 61 +++++ Tusker.xcodeproj/project.pbxproj | 2 + 15 files changed, 805 insertions(+), 37 deletions(-) create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Controllers/FocusedAttachmentController.swift create mode 100644 Packages/MatchedGeometryPresentation/.gitignore create mode 100644 Packages/MatchedGeometryPresentation/Package.swift create mode 100644 Packages/MatchedGeometryPresentation/Sources/MatchedGeometryPresentation/MatchedGeometryModifiers.swift create mode 100644 Packages/MatchedGeometryPresentation/Sources/MatchedGeometryPresentation/MatchedGeometryViewController.swift create mode 100644 Packages/MatchedGeometryPresentation/Sources/MatchedGeometryPresentation/View+PresentViewController.swift diff --git a/Packages/ComposeUI/Package.swift b/Packages/ComposeUI/Package.swift index 1de4d548f6..adbd2bbaaa 100644 --- a/Packages/ComposeUI/Package.swift +++ b/Packages/ComposeUI/Package.swift @@ -19,13 +19,14 @@ let package = Package( .package(path: "../Pachyderm"), .package(path: "../InstanceFeatures"), .package(path: "../TuskerComponents"), + .package(path: "../MatchedGeometryPresentation"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "ComposeUI", - dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents"]), + dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"]), .testTarget( name: "ComposeUITests", dependencies: ["ComposeUI"]), diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift index 9a2c0018ab..83528e5aaf 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift @@ -8,6 +8,7 @@ import SwiftUI import TuskerComponents import Vision +import MatchedGeometryPresentation class AttachmentRowController: ViewController { let parent: ComposeController @@ -15,12 +16,16 @@ class AttachmentRowController: ViewController { @Published var descriptionMode: DescriptionMode = .allowEntry @Published var textRecognitionError: Error? + @Published var focusAttachmentOnTextEditorUnfocus = false + + let thumbnailController: AttachmentThumbnailController private var descriptionObservation: NSKeyValueObservation? init(parent: ComposeController, attachment: DraftAttachment) { self.parent = parent self.attachment = attachment + self.thumbnailController = AttachmentThumbnailController(attachment: attachment) descriptionObservation = attachment.observe(\.attachmentDescription, changeHandler: { [unowned self] _, _ in // the faultingState is non-zero for objects that are being cascade deleted when the draft is deleted @@ -57,6 +62,11 @@ class AttachmentRowController: ViewController { } } + private func focusAttachment() { + focusAttachmentOnTextEditorUnfocus = false + parent.focusedAttachment = (attachment, thumbnailController) + } + private func recognizeText() { descriptionMode = .recognizingText @@ -109,6 +119,7 @@ class AttachmentRowController: ViewController { struct AttachmentView: View { @ObservedObject private var attachment: DraftAttachment @EnvironmentObject private var controller: AttachmentRowController + @FocusState private var textEditorFocused: Bool init(attachment: DraftAttachment) { self.attachment = attachment @@ -116,9 +127,19 @@ class AttachmentRowController: ViewController { var body: some View { HStack(alignment: .center, spacing: 4) { - AttachmentThumbnailView(attachment: attachment, fullSize: false) + ControllerView(controller: { controller.thumbnailController }) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .environment(\.attachmentThumbnailConfiguration, .init(contentMode: .fit, fullSize: false)) + .matchedGeometrySource(id: attachment.id, presentationID: attachment.id) + .overlay { + thumbnailFocusedOverlay + } .frame(width: 80, height: 80) - .cornerRadius(8) + .onTapGesture { + textEditorFocused = false + // if we just focus the attachment immediately, the text editor doesn't actually unfocus + controller.focusAttachmentOnTextEditorUnfocus = true + } .contextMenu { if attachment.drawingData != nil { Button(action: controller.editDrawing) { @@ -129,7 +150,7 @@ class AttachmentRowController: ViewController { Label("Recognize Text", systemImage: "doc.text.viewfinder") } } - + Button(role: .destructive, action: controller.removeAttachment) { Label("Delete", systemImage: "trash") } @@ -139,11 +160,9 @@ class AttachmentRowController: ViewController { switch controller.descriptionMode { case .allowEntry: - AttachmentDescriptionTextView( - text: $attachment.attachmentDescription, - placeholder: Text("Describe for the visually impaired…"), - minHeight: 80 - ) + InlineAttachmentDescriptionView(attachment: attachment, minHeight: 80) + .matchedGeometrySource(id: AttachmentDescriptionTextViewID(attachment), presentationID: attachment.id) + .focused($textEditorFocused) case .recognizingText: ProgressView() @@ -156,7 +175,25 @@ class AttachmentRowController: ViewController { Text(error.localizedDescription) } .onAppear(perform: controller.updateAttachmentDescriptionState) + .onChange(of: textEditorFocused) { newValue in + if !newValue && controller.focusAttachmentOnTextEditorUnfocus { + controller.focusAttachment() + } + } } + + @ViewBuilder + private var thumbnailFocusedOverlay: some View { + Image(systemName: "arrow.up.backward.and.arrow.down.forward") + .foregroundColor(.white) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.35)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + // use .opacity and an animation, because .transition doesn't seem to play nice with @FocusState + .opacity(textEditorFocused ? 1 : 0) + .animation(.linear(duration: 0.1), value: textEditorFocused) + } + } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift index 3032c86c4a..ee80b9a2b1 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift @@ -148,6 +148,7 @@ class AttachmentsListController: ViewController { ForEach(draft.attachments.array as! [DraftAttachment]) { attachment in ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) }) .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) + .id(attachment.id) } .onMove(perform: controller.moveAttachments) .onDelete(perform: controller.deleteAttachments) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift index b9c03bc079..17ae478bcf 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -9,6 +9,7 @@ import SwiftUI import Combine import Pachyderm import TuskerComponents +import MatchedGeometryPresentation public final class ComposeController: ViewController { public typealias FetchStatus = (String) -> (any StatusProtocol)? @@ -38,6 +39,8 @@ public final class ComposeController: ViewController { // this property is here rather than on the AttachmentsListController so that the ComposeView // updates when it changes, because changes to it may alter postButtonEnabled @Published var attachmentsMissingDescriptions = Set() + @Published var focusedAttachment: (DraftAttachment, AttachmentThumbnailController)? + let scrollToAttachment = PassthroughSubject() @Published var contentWarningBecomeFirstResponder = false @Published var mainComposeTextViewBecomeFirstResponder = false @Published var currentInput: (any ComposeInput)? = nil @@ -256,7 +259,12 @@ public final class ComposeController: ViewController { config.backgroundColor .edgesIgnoringSafeArea(.all) - mainList + ScrollViewReader { proxy in + mainList + .onReceive(controller.scrollToAttachment) { id in + proxy.scrollTo(id, anchor: .center) + } + } if let poster = poster { // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149 @@ -296,6 +304,23 @@ public final class ComposeController: ViewController { }, message: { error in Text(error.localizedDescription) }) + .matchedGeometryPresentation(id: Binding(get: { + controller.focusedAttachment?.0.id + }, set: { + if $0 == nil { + controller.focusedAttachment = nil + } else { + fatalError() + } + }), backgroundColor: .black) { + ControllerView(controller: { + FocusedAttachmentController( + parent: controller, + attachment: controller.focusedAttachment!.0, + thumbnailController: controller.focusedAttachment!.1 + ) + }) + } .onDisappear(perform: controller.onDisappear) .navigationTitle(controller.navigationTitle) .navigationBarTitleDisplayMode(.inline) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift index 71f8bd6042..f54a5d61d2 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/DraftsController.swift @@ -135,8 +135,9 @@ private struct DraftRow: View { HStack(spacing: 8) { ForEach(draft.draftAttachments) { attachment in AttachmentThumbnailView(attachment: attachment, fullSize: false) - .frame(width: 50, height: 50) - .cornerRadius(5) + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .frame(height: 50) } } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/FocusedAttachmentController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/FocusedAttachmentController.swift new file mode 100644 index 0000000000..fd769710d5 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/FocusedAttachmentController.swift @@ -0,0 +1,95 @@ +// +// FocusedAttachmentController.swift +// ComposeUI +// +// Created by Shadowfacts on 4/29/23. +// + +import SwiftUI +import MatchedGeometryPresentation + +class FocusedAttachmentController: ViewController { + + unowned let parent: ComposeController + let attachment: DraftAttachment + let thumbnailController: AttachmentThumbnailController + + init(parent: ComposeController, attachment: DraftAttachment, thumbnailController: AttachmentThumbnailController) { + self.parent = parent + self.attachment = attachment + self.thumbnailController = thumbnailController + } + + var view: some View { + FocusedAttachmentView(attachment: attachment) + } + + struct FocusedAttachmentView: View { + @ObservedObject var attachment: DraftAttachment + @EnvironmentObject private var controller: FocusedAttachmentController + @Environment(\.dismiss) private var dismiss + @FocusState private var textEditorFocused: Bool + @EnvironmentObject private var matchedGeomState: MatchedGeometryState + + var body: some View { + VStack(spacing: 0) { + Spacer(minLength: 0) + + ControllerView(controller: { controller.thumbnailController }) + .environment(\.attachmentThumbnailConfiguration, .init(contentMode: .fit, fullSize: true)) + .matchedGeometryDestination(id: attachment.id) + + Spacer(minLength: 0) + + FocusedAttachmentDescriptionView(attachment: attachment) + .environment(\.colorScheme, .dark) + .matchedGeometryDestination(id: AttachmentDescriptionTextViewID(attachment)) + .frame(height: 150) + .focused($textEditorFocused) + } + .background(.black) + .overlay(alignment: .topLeading, content: { + Button { + // set the mode to dismissing immediately, so that layout changes due to the keyboard hiding + // (which happens before the dismiss animation controller starts running) don't alter the destination frames + if textEditorFocused { + matchedGeomState.mode = .dismissing + } + dismiss() + } label: { + Image(systemName: "arrow.down.forward.and.arrow.up.backward") + } + .buttonStyle(DismissFocusedAttachmentButtonStyle()) + .padding([.top, .leading], 4) + }) + } + } +} + +private struct DismissFocusedAttachmentButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + ZStack { + RoundedRectangle(cornerRadius: 4) + .fill(.black.opacity(0.5)) + + configuration.label + .foregroundColor(.white) + .imageScale(.large) + } + .frame(width: 40, height: 40) + } +} + +struct AttachmentDescriptionTextViewID: Hashable { + let attachmentID: UUID + + init(_ attachment: DraftAttachment) { + self.attachmentID = attachment.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(attachmentID) + hasher.combine("descriptionTextView") + } +} + diff --git a/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift b/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift index 323dfe8372..8ecbc6e47e 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift @@ -10,7 +10,13 @@ import Combine @available(iOS, obsoleted: 16.0) class KeyboardReader: ObservableObject { - @Published var isVisible = false +// @Published var isVisible = false + @Published var keyboardHeight: CGFloat = 0 + + var isVisible: Bool { + // when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible" + keyboardHeight > 72 + } init() { NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil) @@ -18,12 +24,16 @@ class KeyboardReader: ObservableObject { } @objc func willShow(_ notification: Foundation.Notification) { - // when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible" let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect - isVisible = endFrame.height > 72 +// isVisible = endFrame.height > 72 + keyboardHeight = endFrame.height } @objc func willHide() { - isVisible = false + // sometimes willHide is called during a SwiftUI view update + DispatchQueue.main.async { +// self.isVisible = false + self.keyboardHeight = 0 + } } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentDescriptionTextView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentDescriptionTextView.swift index 4f7dd68b23..5f1bbf0958 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentDescriptionTextView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentDescriptionTextView.swift @@ -7,22 +7,24 @@ import SwiftUI -struct AttachmentDescriptionTextView: View { - @Binding private var text: String - private let placeholder: Text? +private var placeholder: some View { + Text("Describe for the visually impaired…") +} + +struct InlineAttachmentDescriptionView: View { + @ObservedObject private var attachment: DraftAttachment private let minHeight: CGFloat @State private var height: CGFloat? - init(text: Binding, placeholder: Text?, minHeight: CGFloat) { - self._text = text - self.placeholder = placeholder + init(attachment: DraftAttachment, minHeight: CGFloat) { + self.attachment = attachment self.minHeight = minHeight } var body: some View { ZStack(alignment: .topLeading) { - if text.isEmpty, let placeholder { + if attachment.attachmentDescription.isEmpty { placeholder .font(.body) .foregroundColor(.secondary) @@ -30,9 +32,9 @@ struct AttachmentDescriptionTextView: View { } WrappedTextView( - text: $text, - textDidChange: self.textDidChange, - font: .preferredFont(forTextStyle: .body) + text: $attachment.attachmentDescription, + backgroundColor: .clear, + textDidChange: self.textDidChange ) .frame(height: height ?? minHeight) } @@ -43,20 +45,43 @@ struct AttachmentDescriptionTextView: View { } } +struct FocusedAttachmentDescriptionView: View { + @ObservedObject var attachment: DraftAttachment + + var body: some View { + ZStack(alignment: .topLeading) { + WrappedTextView( + text: $attachment.attachmentDescription, + backgroundColor: .secondarySystemBackground, + textDidChange: nil + ) + .edgesIgnoringSafeArea([.bottom, .leading, .trailing]) + + if attachment.attachmentDescription.isEmpty { + placeholder + .font(.body) + .foregroundColor(.secondary) + .offset(x: 4, y: 8) + .allowsHitTesting(false) + } + } + } +} + private struct WrappedTextView: UIViewRepresentable { typealias UIViewType = UITextView @Binding var text: String - let textDidChange: ((UITextView) -> Void) - let font: UIFont + let backgroundColor: UIColor + let textDidChange: (((UITextView) -> Void))? @Environment(\.isEnabled) private var isEnabled func makeUIView(context: Context) -> UITextView { let view = UITextView() view.delegate = context.coordinator - view.backgroundColor = .clear - view.font = font + view.backgroundColor = backgroundColor + view.font = .preferredFont(forTextStyle: .body) view.adjustsFontForContentSizeCategory = true view.textContainer.lineBreakMode = .byWordWrapping return view @@ -68,10 +93,12 @@ private struct WrappedTextView: UIViewRepresentable { context.coordinator.textView = uiView context.coordinator.text = $text context.coordinator.didChange = textDidChange - // wait until the next runloop iteration so that SwiftUI view updates have finished and - // the text view knows its new content size - DispatchQueue.main.async { - self.textDidChange(uiView) + if let textDidChange { + // wait until the next runloop iteration so that SwiftUI view updates have finished and + // the text view knows its new content size + DispatchQueue.main.async { + textDidChange(uiView) + } } } @@ -82,10 +109,10 @@ private struct WrappedTextView: UIViewRepresentable { class Coordinator: NSObject, UITextViewDelegate, TextViewCaretScrolling { weak var textView: UITextView? var text: Binding - var didChange: (UITextView) -> Void + var didChange: ((UITextView) -> Void)? var caretScrollPositionAnimator: UIViewPropertyAnimator? - init(text: Binding, didChange: @escaping (UITextView) -> Void) { + init(text: Binding, didChange: ((UITextView) -> Void)?) { self.text = text self.didChange = didChange @@ -104,7 +131,7 @@ private struct WrappedTextView: UIViewRepresentable { func textViewDidChange(_ textView: UITextView) { text.wrappedValue = textView.text - didChange(textView) + didChange?(textView) ensureCursorVisible(textView: textView) } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift index 77c0947169..9c7d5f270d 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift @@ -10,6 +10,120 @@ import SwiftUI import Photos import TuskerComponents +class AttachmentThumbnailController: ViewController { + let attachment: DraftAttachment + + @Published private var image: UIImage? + @Published private var fullSize: Bool = false + + init(attachment: DraftAttachment) { + self.attachment = attachment + } + + func loadImageIfNecessary(fullSize: Bool) { + guard image == nil || (fullSize && !self.fullSize) else { + return + } + self.fullSize = fullSize + + switch attachment.data { + case .asset(let id): + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else { + return + } + let size: CGSize + if fullSize { + size = PHImageManagerMaximumSize + } else { + // currently only used as thumbnail in ComposeAttachmentRow + size = CGSize(width: 80, height: 80) + } + // todo: gifs + PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in + DispatchQueue.main.async { + self.image = image + } + } + + case .drawing(_): + // todo + break + + case .file(let url, let type): + // todo: videos + if let data = try? Data(contentsOf: url) { + // todo: gifs + if type.conforms(to: .image), + let image = UIImage(data: data) { + if fullSize { + image.prepareForDisplay { prepared in + DispatchQueue.main.async { + self.image = prepared + } + } + } else { + image.prepareThumbnail(of: CGSize(width: 80, height: 80)) { prepared in + DispatchQueue.main.async { + self.image = prepared + } + } + } + } + } + } + } + + var view: some SwiftUI.View { + View() + } + + struct View: SwiftUI.View { + @EnvironmentObject private var controller: AttachmentThumbnailController + @Environment(\.attachmentThumbnailConfiguration) private var config + + var body: some SwiftUI.View { + content + .onAppear { + controller.loadImageIfNecessary(fullSize: config.fullSize) + } + } + + @ViewBuilder + private var content: some SwiftUI.View { + if let image = controller.image { + Image(uiImage: image) + .resizable() + .aspectRatio(config.aspectRatio, contentMode: config.contentMode) + } else { + Image(systemName: "photo") + } + } + } +} + +struct AttachmentThumbnailConfiguration { + let aspectRatio: CGFloat? + let contentMode: ContentMode + let fullSize: Bool + + init(aspectRatio: CGFloat? = nil, contentMode: ContentMode = .fit, fullSize: Bool = false) { + self.aspectRatio = aspectRatio + self.contentMode = contentMode + self.fullSize = fullSize + } +} + +private struct AttachmentThumbnailConfigurationEnvironmentKey: EnvironmentKey { + static let defaultValue = AttachmentThumbnailConfiguration() +} + +extension EnvironmentValues { + var attachmentThumbnailConfiguration: AttachmentThumbnailConfiguration { + get { self[AttachmentThumbnailConfigurationEnvironmentKey.self] } + set { self[AttachmentThumbnailConfigurationEnvironmentKey.self] = newValue } + } +} + struct AttachmentThumbnailView: View { let attachment: DraftAttachment let fullSize: Bool diff --git a/Packages/MatchedGeometryPresentation/.gitignore b/Packages/MatchedGeometryPresentation/.gitignore new file mode 100644 index 0000000000..3b29812086 --- /dev/null +++ b/Packages/MatchedGeometryPresentation/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/MatchedGeometryPresentation/Package.swift b/Packages/MatchedGeometryPresentation/Package.swift new file mode 100644 index 0000000000..e293b7d72f --- /dev/null +++ b/Packages/MatchedGeometryPresentation/Package.swift @@ -0,0 +1,26 @@ +// swift-tools-version: 5.8 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MatchedGeometryPresentation", + platforms: [ + .iOS(.v15), + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "MatchedGeometryPresentation", + targets: ["MatchedGeometryPresentation"]), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "MatchedGeometryPresentation"), + // .testTarget( + // name: "MatchedGeometryPresentationTests", + // dependencies: ["MatchedGeometryPresentation"]), + ] +) diff --git a/Packages/MatchedGeometryPresentation/Sources/MatchedGeometryPresentation/MatchedGeometryModifiers.swift b/Packages/MatchedGeometryPresentation/Sources/MatchedGeometryPresentation/MatchedGeometryModifiers.swift new file mode 100644 index 0000000000..ffec5bff93 --- /dev/null +++ b/Packages/MatchedGeometryPresentation/Sources/MatchedGeometryPresentation/MatchedGeometryModifiers.swift @@ -0,0 +1,125 @@ +// +// MatchedGeometryModifiers.swift +// MatchGeom +// +// Created by Shadowfacts on 4/24/23. +// + +import SwiftUI + +extension View { + public func matchedGeometryPresentation(id: Binding, backgroundColor: UIColor, @ViewBuilder presenting: () -> Presented) -> some View { + self.modifier(MatchedGeometryPresentationModifier(id: id, backgroundColor: backgroundColor, presented: presenting())) + } + + public func matchedGeometrySource(id: ID, presentationID: ID2) -> some View { + self.modifier(MatchedGeometrySourceModifier(id: AnyHashable(id), presentationID: AnyHashable(presentationID), matched: { AnyView(self) })) + } + + public func matchedGeometryDestination(id: ID) -> some View { + self.modifier(MatchedGeometryDestinationModifier(id: AnyHashable(id), matched: self)) + } +} + +private struct MatchedGeometryPresentationModifier: ViewModifier { + @Binding var id: ID? + let backgroundColor: UIColor + let presented: Presented + @StateObject private var state = MatchedGeometryState() + + private var isPresented: Binding { + Binding { + id != nil + } set: { + if $0 { + fatalError() + } else { + id = nil + } + } + } + + func body(content: Content) -> some View { + content + .environmentObject(state) + .backgroundPreferenceValue(MatchedGeometrySourcesKey.self, { sources in + Color.clear + .presentViewController(makeVC(allSources: sources), isPresented: isPresented) + }) + } + + private func makeVC(allSources: [SourceKey: (AnyView, CGRect)]) -> () -> UIViewController { + return { + // force unwrap is safe, this closure is only called when being presented so we must have an id + let id = AnyHashable(id!) + return MatchedGeometryViewController( + presentationID: id, + content: presented, + state: state, + backgroundColor: backgroundColor + ) + } + } +} + +private struct MatchedGeometrySourceModifier: ViewModifier { + let id: AnyHashable + let presentationID: AnyHashable + let matched: () -> AnyView + @EnvironmentObject private var state: MatchedGeometryState + + func body(content: Content) -> some View { + content + .background(GeometryReader { proxy in + Color.clear + .preference(key: MatchedGeometryDestinationFrameKey.self, value: proxy.frame(in: .global)) + .onPreferenceChange(MatchedGeometryDestinationFrameKey.self) { newValue in + if let newValue { + state.sources[SourceKey(presentationID: presentationID, matchedID: id)] = (matched, newValue) + } + } + }) + .opacity(state.animating && state.presentationID == presentationID ? 0 : 1) + } +} + +private struct MatchedGeometryDestinationModifier: ViewModifier { + let id: AnyHashable + let matched: Matched + @EnvironmentObject private var state: MatchedGeometryState + + func body(content: Content) -> some View { + content + .background(GeometryReader { proxy in + Color.clear + .preference(key: MatchedGeometryDestinationFrameKey.self, value: proxy.frame(in: .global)) + .onPreferenceChange(MatchedGeometryDestinationFrameKey.self) { newValue in + if let newValue, + // ignore intermediate layouts that may happen while the dismiss animation is happening + state.mode != .dismissing { + state.destinations[id] = (AnyView(matched), newValue) + } + } + }) + .opacity(state.animating ? 0 : 1) + } +} + +private struct MatchedGeometryDestinationFrameKey: PreferenceKey { + static let defaultValue: CGRect? = nil + static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) { + value = nextValue() + } +} + +private struct MatchedGeometrySourcesKey: PreferenceKey { + static let defaultValue: [SourceKey: (AnyView, CGRect)] = [:] + static func reduce(value: inout Value, nextValue: () -> Value) { + value.merge(nextValue(), uniquingKeysWith: { _, new in new }) + } +} + +struct SourceKey: Hashable { + let presentationID: AnyHashable + let matchedID: AnyHashable +} diff --git a/Packages/MatchedGeometryPresentation/Sources/MatchedGeometryPresentation/MatchedGeometryViewController.swift b/Packages/MatchedGeometryPresentation/Sources/MatchedGeometryPresentation/MatchedGeometryViewController.swift new file mode 100644 index 0000000000..aea8898ed5 --- /dev/null +++ b/Packages/MatchedGeometryPresentation/Sources/MatchedGeometryPresentation/MatchedGeometryViewController.swift @@ -0,0 +1,234 @@ +// +// MatchedGeometryViewController.swift +// MatchGeom +// +// Created by Shadowfacts on 4/24/23. +// + +import SwiftUI +import Combine + +private let mass: CGFloat = 1 +private let presentStiffness: CGFloat = 300 +private let presentDamping: CGFloat = 20 +private let dismissStiffness: CGFloat = 200 +private let dismissDamping: CGFloat = 20 + +public class MatchedGeometryState: ObservableObject { + @Published var presentationID: AnyHashable? + @Published var animating: Bool = false + @Published public var mode: Mode = .presenting + @Published var sources: [SourceKey: (() -> AnyView, CGRect)] = [:] + @Published var currentFrames: [AnyHashable: CGRect] = [:] + @Published var destinations: [AnyHashable: (AnyView, CGRect)] = [:] + + public enum Mode: Equatable { + case presenting + case idle + case dismissing + } +} + +class MatchedGeometryViewController: UIViewController, UIViewControllerTransitioningDelegate { + + let presentationID: AnyHashable + let content: Content + let state: MatchedGeometryState + let backgroundColor: UIColor + var contentHost: UIHostingController! + var matchedHost: UIHostingController! + + init(presentationID: AnyHashable, content: Content, state: MatchedGeometryState, backgroundColor: UIColor) { + self.presentationID = presentationID + self.content = content + self.state = state + self.backgroundColor = backgroundColor + + super.init(nibName: nil, bundle: nil) + + modalPresentationStyle = .custom + transitioningDelegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + contentHost = UIHostingController(rootView: ContentContainerView(content: content, state: state)) + contentHost.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + contentHost.view.frame = view.bounds + contentHost.view.backgroundColor = backgroundColor + addChild(contentHost) + view.addSubview(contentHost.view) + contentHost.didMove(toParent: self) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + state.presentationID = presentationID + } + + var currentPresentationSources: [AnyHashable: (() -> AnyView, CGRect)] { + Dictionary(uniqueKeysWithValues: state.sources.filter { $0.key.presentationID == presentationID }.map { ($0.key.matchedID, $0.value) }) + } + + func addMatchedHostingController() { + let sources = currentPresentationSources.map { (id: $0.key, view: $0.value.0) } + matchedHost = UIHostingController(rootView: MatchedContainerView(sources: sources, state: state)) + matchedHost.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + matchedHost.view.frame = view.bounds + matchedHost.view.backgroundColor = .clear + matchedHost.view.layer.zPosition = 100 + addChild(matchedHost) + view.addSubview(matchedHost.view) + matchedHost.didMove(toParent: self) + } + + struct ContentContainerView: View { + let content: Content + let state: MatchedGeometryState + + var body: some View { + content + .environmentObject(state) + } + } + + struct MatchedContainerView: View { + let sources: [(id: AnyHashable, view: () -> AnyView)] + @ObservedObject var state: MatchedGeometryState + + var body: some View { + ZStack { + ForEach(sources, id: \.id) { (id, view) in + matchedView(id: id, source: view) + } + } + } + + func matchedView(id: AnyHashable, source: () -> AnyView) -> some View { + let frame = state.currentFrames[id]! + let dest = state.destinations[id]!.0 + return ZStack { + source() + dest + .opacity(state.mode == .presenting ? (state.animating ? 1 : 0) : (state.animating ? 0 : 1)) + } + .frame(width: frame.width, height: frame.height) + .position(x: frame.midX, y: frame.midY) + .ignoresSafeArea() + .animation(.interpolatingSpring(mass: Double(mass), stiffness: Double(state.mode == .presenting ? presentStiffness : dismissStiffness), damping: Double(state.mode == .presenting ? presentDamping : dismissDamping), initialVelocity: 0), value: frame) + } + } + + // MARK: UIViewControllerTransitioningDelegate + func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + return MatchedGeometryPresentationAnimationController() + } + + func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + return MatchedGeometryDismissAnimationController() + } + + func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { + return MatchedGeometryPresentationController(presentedViewController: presented, presenting: presenting) + } + +} + +class MatchedGeometryPresentationAnimationController: NSObject, UIViewControllerAnimatedTransitioning { + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.8 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + let matchedGeomVC = transitionContext.viewController(forKey: .to) as! MatchedGeometryViewController + let container = transitionContext.containerView + + // add the VC to the container, which kicks off layout out the content hosting controller + container.addSubview(matchedGeomVC.view) + + // layout out the content hosting controller and having enough destinations may take a while + // so listen for when it's ready, rather than trying to guess at the timing + let cancellable = matchedGeomVC.state.$destinations + .filter { destinations in matchedGeomVC.currentPresentationSources.allSatisfy { source in destinations.keys.contains(source.key) } } + .first() + .sink { destinations in + matchedGeomVC.addMatchedHostingController() + + // setup the initial state for the animation + matchedGeomVC.matchedHost.view.isHidden = true + matchedGeomVC.state.mode = .presenting + matchedGeomVC.state.currentFrames = matchedGeomVC.currentPresentationSources.mapValues(\.1) + + // wait one runloop iteration for the matched hosting controller to be setup + DispatchQueue.main.async { + matchedGeomVC.matchedHost.view.isHidden = false + matchedGeomVC.state.animating = true + // get the now-current destinations, in case they've changed since the sunk value was published + matchedGeomVC.state.currentFrames = matchedGeomVC.state.destinations.mapValues(\.1) + } + } + + matchedGeomVC.contentHost.view.layer.opacity = 0 + let spring = UISpringTimingParameters(mass: mass, stiffness: presentStiffness, damping: presentDamping, initialVelocity: .zero) + let animator = UIViewPropertyAnimator(duration: self.transitionDuration(using: transitionContext), timingParameters: spring) + animator.addAnimations { + matchedGeomVC.contentHost.view.layer.opacity = 1 + } + animator.addCompletion { _ in + transitionContext.completeTransition(true) + matchedGeomVC.state.animating = false + matchedGeomVC.state.mode = .idle + + matchedGeomVC.matchedHost?.view.removeFromSuperview() + matchedGeomVC.matchedHost?.removeFromParent() + cancellable.cancel() + } + animator.startAnimation() + } +} + +class MatchedGeometryDismissAnimationController: NSObject, UIViewControllerAnimatedTransitioning { + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.8 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + let matchedGeomVC = transitionContext.viewController(forKey: .from) as! MatchedGeometryViewController + + // recreate the matched host b/c using the current destinations doesn't seem to update the existing one + matchedGeomVC.addMatchedHostingController() + matchedGeomVC.matchedHost.view.isHidden = true + matchedGeomVC.state.mode = .dismissing + matchedGeomVC.state.currentFrames = matchedGeomVC.state.destinations.mapValues(\.1) + + DispatchQueue.main.async { + matchedGeomVC.matchedHost.view.isHidden = false + matchedGeomVC.state.animating = true + matchedGeomVC.state.currentFrames = matchedGeomVC.currentPresentationSources.mapValues(\.1) + } + + let spring = UISpringTimingParameters(mass: mass, stiffness: dismissStiffness, damping: dismissDamping, initialVelocity: .zero) + let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), timingParameters: spring) + animator.addAnimations { + matchedGeomVC.contentHost.view.layer.opacity = 0 + } + animator.addCompletion { _ in + transitionContext.completeTransition(true) + matchedGeomVC.state.animating = false + } + animator.startAnimation() + } +} + +class MatchedGeometryPresentationController: UIPresentationController { + override func dismissalTransitionWillBegin() { + super.dismissalTransitionWillBegin() + delegate?.presentationControllerWillDismiss?(self) + } +} diff --git a/Packages/MatchedGeometryPresentation/Sources/MatchedGeometryPresentation/View+PresentViewController.swift b/Packages/MatchedGeometryPresentation/Sources/MatchedGeometryPresentation/View+PresentViewController.swift new file mode 100644 index 0000000000..47d283e0c6 --- /dev/null +++ b/Packages/MatchedGeometryPresentation/Sources/MatchedGeometryPresentation/View+PresentViewController.swift @@ -0,0 +1,61 @@ +// +// View+PresentViewController.swift +// MatchGeom +// +// Created by Shadowfacts on 4/24/23. +// + +import SwiftUI + +extension View { + func presentViewController(_ makeVC: @escaping () -> UIViewController, isPresented: Binding) -> some View { + self + .background( + ViewControllerPresenter(makeVC: makeVC, isPresented: isPresented) + ) + } +} + +private struct ViewControllerPresenter: UIViewControllerRepresentable { + let makeVC: () -> UIViewController + @Binding var isPresented: Bool + + func makeUIViewController(context: Context) -> UIViewController { + return UIViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + if isPresented { + if uiViewController.presentedViewController == nil { + let presented = makeVC() + presented.presentationController!.delegate = context.coordinator + uiViewController.present(presented, animated: true) + context.coordinator.didPresent = true + } + } else { + if context.coordinator.didPresent, + let presentedViewController = uiViewController.presentedViewController, + !presentedViewController.isBeingDismissed { + uiViewController.dismiss(animated: true) + context.coordinator.didPresent = false + } + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(isPresented: $isPresented) + } + + class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate { + @Binding var isPresented: Bool + var didPresent = false + + init(isPresented: Binding) { + self._isPresented = isPresented + } + + func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { + isPresented = false + } + } +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index e9c16c3452..4baadccd23 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -634,6 +634,7 @@ D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = ""; }; D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = ""; }; D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = ""; }; + D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = MatchedGeometryPresentation; path = Packages/MatchedGeometryPresentation; sourceTree = ""; }; D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = ""; }; D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = ""; }; D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusCollectionViewCell.swift; sourceTree = ""; }; @@ -1457,6 +1458,7 @@ D6BD395C29B789D5005FFD2B /* TuskerComponents */, D6BD395729B6441F005FFD2B /* ComposeUI */, D6CA6ED029EF6060003EC5DF /* TuskerPreferences */, + D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */, D6D4DDCE212518A000E1C4BB /* Tusker */, D6D4DDE3212518A200E1C4BB /* TuskerTests */, D6D4DDEE212518A200E1C4BB /* TuskerUITests */,