forked from shadowfacts/Tusker
parent
e0eba95b48
commit
891fd3826b
|
@ -19,13 +19,14 @@ let package = Package(
|
||||||
.package(path: "../Pachyderm"),
|
.package(path: "../Pachyderm"),
|
||||||
.package(path: "../InstanceFeatures"),
|
.package(path: "../InstanceFeatures"),
|
||||||
.package(path: "../TuskerComponents"),
|
.package(path: "../TuskerComponents"),
|
||||||
|
.package(path: "../MatchedGeometryPresentation"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
// 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.
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
.target(
|
.target(
|
||||||
name: "ComposeUI",
|
name: "ComposeUI",
|
||||||
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents"]),
|
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"]),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "ComposeUITests",
|
name: "ComposeUITests",
|
||||||
dependencies: ["ComposeUI"]),
|
dependencies: ["ComposeUI"]),
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import TuskerComponents
|
import TuskerComponents
|
||||||
import Vision
|
import Vision
|
||||||
|
import MatchedGeometryPresentation
|
||||||
|
|
||||||
class AttachmentRowController: ViewController {
|
class AttachmentRowController: ViewController {
|
||||||
let parent: ComposeController
|
let parent: ComposeController
|
||||||
|
@ -15,12 +16,16 @@ class AttachmentRowController: ViewController {
|
||||||
|
|
||||||
@Published var descriptionMode: DescriptionMode = .allowEntry
|
@Published var descriptionMode: DescriptionMode = .allowEntry
|
||||||
@Published var textRecognitionError: Error?
|
@Published var textRecognitionError: Error?
|
||||||
|
@Published var focusAttachmentOnTextEditorUnfocus = false
|
||||||
|
|
||||||
|
let thumbnailController: AttachmentThumbnailController
|
||||||
|
|
||||||
private var descriptionObservation: NSKeyValueObservation?
|
private var descriptionObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
init(parent: ComposeController, attachment: DraftAttachment) {
|
init(parent: ComposeController, attachment: DraftAttachment) {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
self.attachment = attachment
|
self.attachment = attachment
|
||||||
|
self.thumbnailController = AttachmentThumbnailController(attachment: attachment)
|
||||||
|
|
||||||
descriptionObservation = attachment.observe(\.attachmentDescription, changeHandler: { [unowned self] _, _ in
|
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
|
// 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() {
|
private func recognizeText() {
|
||||||
descriptionMode = .recognizingText
|
descriptionMode = .recognizingText
|
||||||
|
|
||||||
|
@ -109,6 +119,7 @@ class AttachmentRowController: ViewController {
|
||||||
struct AttachmentView: View {
|
struct AttachmentView: View {
|
||||||
@ObservedObject private var attachment: DraftAttachment
|
@ObservedObject private var attachment: DraftAttachment
|
||||||
@EnvironmentObject private var controller: AttachmentRowController
|
@EnvironmentObject private var controller: AttachmentRowController
|
||||||
|
@FocusState private var textEditorFocused: Bool
|
||||||
|
|
||||||
init(attachment: DraftAttachment) {
|
init(attachment: DraftAttachment) {
|
||||||
self.attachment = attachment
|
self.attachment = attachment
|
||||||
|
@ -116,9 +127,19 @@ class AttachmentRowController: ViewController {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center, spacing: 4) {
|
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)
|
.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 {
|
.contextMenu {
|
||||||
if attachment.drawingData != nil {
|
if attachment.drawingData != nil {
|
||||||
Button(action: controller.editDrawing) {
|
Button(action: controller.editDrawing) {
|
||||||
|
@ -139,11 +160,9 @@ class AttachmentRowController: ViewController {
|
||||||
|
|
||||||
switch controller.descriptionMode {
|
switch controller.descriptionMode {
|
||||||
case .allowEntry:
|
case .allowEntry:
|
||||||
AttachmentDescriptionTextView(
|
InlineAttachmentDescriptionView(attachment: attachment, minHeight: 80)
|
||||||
text: $attachment.attachmentDescription,
|
.matchedGeometrySource(id: AttachmentDescriptionTextViewID(attachment), presentationID: attachment.id)
|
||||||
placeholder: Text("Describe for the visually impaired…"),
|
.focused($textEditorFocused)
|
||||||
minHeight: 80
|
|
||||||
)
|
|
||||||
|
|
||||||
case .recognizingText:
|
case .recognizingText:
|
||||||
ProgressView()
|
ProgressView()
|
||||||
|
@ -156,8 +175,26 @@ class AttachmentRowController: ViewController {
|
||||||
Text(error.localizedDescription)
|
Text(error.localizedDescription)
|
||||||
}
|
}
|
||||||
.onAppear(perform: controller.updateAttachmentDescriptionState)
|
.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -148,6 +148,7 @@ class AttachmentsListController: ViewController {
|
||||||
ForEach(draft.attachments.array as! [DraftAttachment]) { attachment in
|
ForEach(draft.attachments.array as! [DraftAttachment]) { attachment in
|
||||||
ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) })
|
ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) })
|
||||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||||
|
.id(attachment.id)
|
||||||
}
|
}
|
||||||
.onMove(perform: controller.moveAttachments)
|
.onMove(perform: controller.moveAttachments)
|
||||||
.onDelete(perform: controller.deleteAttachments)
|
.onDelete(perform: controller.deleteAttachments)
|
||||||
|
|
|
@ -9,6 +9,7 @@ import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import TuskerComponents
|
import TuskerComponents
|
||||||
|
import MatchedGeometryPresentation
|
||||||
|
|
||||||
public final class ComposeController: ViewController {
|
public final class ComposeController: ViewController {
|
||||||
public typealias FetchStatus = (String) -> (any StatusProtocol)?
|
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
|
// this property is here rather than on the AttachmentsListController so that the ComposeView
|
||||||
// updates when it changes, because changes to it may alter postButtonEnabled
|
// updates when it changes, because changes to it may alter postButtonEnabled
|
||||||
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
||||||
|
@Published var focusedAttachment: (DraftAttachment, AttachmentThumbnailController)?
|
||||||
|
let scrollToAttachment = PassthroughSubject<UUID, Never>()
|
||||||
@Published var contentWarningBecomeFirstResponder = false
|
@Published var contentWarningBecomeFirstResponder = false
|
||||||
@Published var mainComposeTextViewBecomeFirstResponder = false
|
@Published var mainComposeTextViewBecomeFirstResponder = false
|
||||||
@Published var currentInput: (any ComposeInput)? = nil
|
@Published var currentInput: (any ComposeInput)? = nil
|
||||||
|
@ -256,7 +259,12 @@ public final class ComposeController: ViewController {
|
||||||
config.backgroundColor
|
config.backgroundColor
|
||||||
.edgesIgnoringSafeArea(.all)
|
.edgesIgnoringSafeArea(.all)
|
||||||
|
|
||||||
|
ScrollViewReader { proxy in
|
||||||
mainList
|
mainList
|
||||||
|
.onReceive(controller.scrollToAttachment) { id in
|
||||||
|
proxy.scrollTo(id, anchor: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let poster = poster {
|
if let poster = poster {
|
||||||
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
|
// 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
|
}, message: { error in
|
||||||
Text(error.localizedDescription)
|
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)
|
.onDisappear(perform: controller.onDisappear)
|
||||||
.navigationTitle(controller.navigationTitle)
|
.navigationTitle(controller.navigationTitle)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
|
|
@ -135,8 +135,9 @@ private struct DraftRow: View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(draft.draftAttachments) { attachment in
|
ForEach(draft.draftAttachments) { attachment in
|
||||||
AttachmentThumbnailView(attachment: attachment, fullSize: false)
|
AttachmentThumbnailView(attachment: attachment, fullSize: false)
|
||||||
.frame(width: 50, height: 50)
|
.aspectRatio(contentMode: .fit)
|
||||||
.cornerRadius(5)
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||||
|
.frame(height: 50)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -10,7 +10,13 @@ import Combine
|
||||||
|
|
||||||
@available(iOS, obsoleted: 16.0)
|
@available(iOS, obsoleted: 16.0)
|
||||||
class KeyboardReader: ObservableObject {
|
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() {
|
init() {
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil)
|
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) {
|
@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
|
let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
|
||||||
isVisible = endFrame.height > 72
|
// isVisible = endFrame.height > 72
|
||||||
|
keyboardHeight = endFrame.height
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func willHide() {
|
@objc func willHide() {
|
||||||
isVisible = false
|
// sometimes willHide is called during a SwiftUI view update
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
// self.isVisible = false
|
||||||
|
self.keyboardHeight = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,22 +7,24 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AttachmentDescriptionTextView: View {
|
private var placeholder: some View {
|
||||||
@Binding private var text: String
|
Text("Describe for the visually impaired…")
|
||||||
private let placeholder: Text?
|
}
|
||||||
|
|
||||||
|
struct InlineAttachmentDescriptionView: View {
|
||||||
|
@ObservedObject private var attachment: DraftAttachment
|
||||||
private let minHeight: CGFloat
|
private let minHeight: CGFloat
|
||||||
|
|
||||||
@State private var height: CGFloat?
|
@State private var height: CGFloat?
|
||||||
|
|
||||||
init(text: Binding<String>, placeholder: Text?, minHeight: CGFloat) {
|
init(attachment: DraftAttachment, minHeight: CGFloat) {
|
||||||
self._text = text
|
self.attachment = attachment
|
||||||
self.placeholder = placeholder
|
|
||||||
self.minHeight = minHeight
|
self.minHeight = minHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
if text.isEmpty, let placeholder {
|
if attachment.attachmentDescription.isEmpty {
|
||||||
placeholder
|
placeholder
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
@ -30,9 +32,9 @@ struct AttachmentDescriptionTextView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
WrappedTextView(
|
WrappedTextView(
|
||||||
text: $text,
|
text: $attachment.attachmentDescription,
|
||||||
textDidChange: self.textDidChange,
|
backgroundColor: .clear,
|
||||||
font: .preferredFont(forTextStyle: .body)
|
textDidChange: self.textDidChange
|
||||||
)
|
)
|
||||||
.frame(height: height ?? minHeight)
|
.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 {
|
private struct WrappedTextView: UIViewRepresentable {
|
||||||
typealias UIViewType = UITextView
|
typealias UIViewType = UITextView
|
||||||
|
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
let textDidChange: ((UITextView) -> Void)
|
let backgroundColor: UIColor
|
||||||
let font: UIFont
|
let textDidChange: (((UITextView) -> Void))?
|
||||||
|
|
||||||
@Environment(\.isEnabled) private var isEnabled
|
@Environment(\.isEnabled) private var isEnabled
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UITextView {
|
func makeUIView(context: Context) -> UITextView {
|
||||||
let view = UITextView()
|
let view = UITextView()
|
||||||
view.delegate = context.coordinator
|
view.delegate = context.coordinator
|
||||||
view.backgroundColor = .clear
|
view.backgroundColor = backgroundColor
|
||||||
view.font = font
|
view.font = .preferredFont(forTextStyle: .body)
|
||||||
view.adjustsFontForContentSizeCategory = true
|
view.adjustsFontForContentSizeCategory = true
|
||||||
view.textContainer.lineBreakMode = .byWordWrapping
|
view.textContainer.lineBreakMode = .byWordWrapping
|
||||||
return view
|
return view
|
||||||
|
@ -68,10 +93,12 @@ private struct WrappedTextView: UIViewRepresentable {
|
||||||
context.coordinator.textView = uiView
|
context.coordinator.textView = uiView
|
||||||
context.coordinator.text = $text
|
context.coordinator.text = $text
|
||||||
context.coordinator.didChange = textDidChange
|
context.coordinator.didChange = textDidChange
|
||||||
|
if let textDidChange {
|
||||||
// wait until the next runloop iteration so that SwiftUI view updates have finished and
|
// wait until the next runloop iteration so that SwiftUI view updates have finished and
|
||||||
// the text view knows its new content size
|
// the text view knows its new content size
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.textDidChange(uiView)
|
textDidChange(uiView)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,10 +109,10 @@ private struct WrappedTextView: UIViewRepresentable {
|
||||||
class Coordinator: NSObject, UITextViewDelegate, TextViewCaretScrolling {
|
class Coordinator: NSObject, UITextViewDelegate, TextViewCaretScrolling {
|
||||||
weak var textView: UITextView?
|
weak var textView: UITextView?
|
||||||
var text: Binding<String>
|
var text: Binding<String>
|
||||||
var didChange: (UITextView) -> Void
|
var didChange: ((UITextView) -> Void)?
|
||||||
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
||||||
|
|
||||||
init(text: Binding<String>, didChange: @escaping (UITextView) -> Void) {
|
init(text: Binding<String>, didChange: ((UITextView) -> Void)?) {
|
||||||
self.text = text
|
self.text = text
|
||||||
self.didChange = didChange
|
self.didChange = didChange
|
||||||
|
|
||||||
|
@ -104,7 +131,7 @@ private struct WrappedTextView: UIViewRepresentable {
|
||||||
|
|
||||||
func textViewDidChange(_ textView: UITextView) {
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
text.wrappedValue = textView.text
|
text.wrappedValue = textView.text
|
||||||
didChange(textView)
|
didChange?(textView)
|
||||||
|
|
||||||
ensureCursorVisible(textView: textView)
|
ensureCursorVisible(textView: textView)
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,120 @@ import SwiftUI
|
||||||
import Photos
|
import Photos
|
||||||
import TuskerComponents
|
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 {
|
struct AttachmentThumbnailView: View {
|
||||||
let attachment: DraftAttachment
|
let attachment: DraftAttachment
|
||||||
let fullSize: Bool
|
let fullSize: Bool
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
/*.xcodeproj
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/config/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
|
@ -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"]),
|
||||||
|
]
|
||||||
|
)
|
|
@ -0,0 +1,125 @@
|
||||||
|
//
|
||||||
|
// MatchedGeometryModifiers.swift
|
||||||
|
// MatchGeom
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/24/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
public func matchedGeometryPresentation<ID: Hashable, Presented: View>(id: Binding<ID?>, backgroundColor: UIColor, @ViewBuilder presenting: () -> Presented) -> some View {
|
||||||
|
self.modifier(MatchedGeometryPresentationModifier(id: id, backgroundColor: backgroundColor, presented: presenting()))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func matchedGeometrySource<ID: Hashable, ID2: Hashable>(id: ID, presentationID: ID2) -> some View {
|
||||||
|
self.modifier(MatchedGeometrySourceModifier(id: AnyHashable(id), presentationID: AnyHashable(presentationID), matched: { AnyView(self) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func matchedGeometryDestination<ID: Hashable>(id: ID) -> some View {
|
||||||
|
self.modifier(MatchedGeometryDestinationModifier(id: AnyHashable(id), matched: self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MatchedGeometryPresentationModifier<ID: Hashable, Presented: View>: ViewModifier {
|
||||||
|
@Binding var id: ID?
|
||||||
|
let backgroundColor: UIColor
|
||||||
|
let presented: Presented
|
||||||
|
@StateObject private var state = MatchedGeometryState()
|
||||||
|
|
||||||
|
private var isPresented: Binding<Bool> {
|
||||||
|
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<Matched: View>: 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
|
||||||
|
}
|
|
@ -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<Content: View>: UIViewController, UIViewControllerTransitioningDelegate {
|
||||||
|
|
||||||
|
let presentationID: AnyHashable
|
||||||
|
let content: Content
|
||||||
|
let state: MatchedGeometryState
|
||||||
|
let backgroundColor: UIColor
|
||||||
|
var contentHost: UIHostingController<ContentContainerView>!
|
||||||
|
var matchedHost: UIHostingController<MatchedContainerView>!
|
||||||
|
|
||||||
|
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<Content>()
|
||||||
|
}
|
||||||
|
|
||||||
|
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||||
|
return MatchedGeometryDismissAnimationController<Content>()
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
|
||||||
|
return MatchedGeometryPresentationController(presentedViewController: presented, presenting: presenting)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class MatchedGeometryPresentationAnimationController<Content: View>: NSObject, UIViewControllerAnimatedTransitioning {
|
||||||
|
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||||
|
return 0.8
|
||||||
|
}
|
||||||
|
|
||||||
|
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||||
|
let matchedGeomVC = transitionContext.viewController(forKey: .to) as! MatchedGeometryViewController<Content>
|
||||||
|
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<Content: View>: NSObject, UIViewControllerAnimatedTransitioning {
|
||||||
|
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||||
|
return 0.8
|
||||||
|
}
|
||||||
|
|
||||||
|
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||||
|
let matchedGeomVC = transitionContext.viewController(forKey: .from) as! MatchedGeometryViewController<Content>
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<Bool>) -> 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<Bool>) {
|
||||||
|
self._isPresented = isPresented
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
|
||||||
|
isPresented = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -634,6 +634,7 @@
|
||||||
D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = "<group>"; };
|
D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = "<group>"; };
|
||||||
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; };
|
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; };
|
||||||
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
|
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
|
||||||
|
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = MatchedGeometryPresentation; path = Packages/MatchedGeometryPresentation; sourceTree = "<group>"; };
|
||||||
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = "<group>"; };
|
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = "<group>"; };
|
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusCollectionViewCell.swift; sourceTree = "<group>"; };
|
D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
|
@ -1457,6 +1458,7 @@
|
||||||
D6BD395C29B789D5005FFD2B /* TuskerComponents */,
|
D6BD395C29B789D5005FFD2B /* TuskerComponents */,
|
||||||
D6BD395729B6441F005FFD2B /* ComposeUI */,
|
D6BD395729B6441F005FFD2B /* ComposeUI */,
|
||||||
D6CA6ED029EF6060003EC5DF /* TuskerPreferences */,
|
D6CA6ED029EF6060003EC5DF /* TuskerPreferences */,
|
||||||
|
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */,
|
||||||
D6D4DDCE212518A000E1C4BB /* Tusker */,
|
D6D4DDCE212518A000E1C4BB /* Tusker */,
|
||||||
D6D4DDE3212518A200E1C4BB /* TuskerTests */,
|
D6D4DDE3212518A200E1C4BB /* TuskerTests */,
|
||||||
D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
|
D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
|
||||||
|
|
Loading…
Reference in New Issue