From fdcfdec60707d49184107964845a815c1f13c77b Mon Sep 17 00:00:00 2001 From: Casper Zandbergen Date: Sun, 5 Jan 2025 15:53:28 +0100 Subject: [PATCH 1/5] QuickLook vc can now be swiped away more consistently --- deltachat-ios/Chat/ChatViewController.swift | 93 +++++++++++++++++-- .../Controller/FilesViewController.swift | 2 +- .../Controller/GalleryViewController.swift | 2 +- .../Controller/PreviewController.swift | 8 +- .../Helper/GiveBackMyFirstResponder.swift | 20 ++++ deltachat-ios/Helper/MediaPicker.swift | 19 +++- 6 files changed, 131 insertions(+), 13 deletions(-) diff --git a/deltachat-ios/Chat/ChatViewController.swift b/deltachat-ios/Chat/ChatViewController.swift index 3c2c1d307..4329074a5 100644 --- a/deltachat-ios/Chat/ChatViewController.swift +++ b/deltachat-ios/Chat/ChatViewController.swift @@ -202,6 +202,9 @@ class ChatViewController: UITableViewController, UITableViewDropDelegate { set { _bag = newValue } } + private var previewControllerTargetSnapshots: [UIView] = [] + private var previewControllerTargetHiddenOriginals: [UIView] = [] + init(dcContext: DcContext, chatId: Int, highlightedMsg: Int? = nil) { self.dcContext = dcContext self.chatId = chatId @@ -259,6 +262,18 @@ class ChatViewController: UITableViewController, UITableViewDropDelegate { // Binding to the tableView will enable interactive dismissal keyboardManager?.bind(to: tableView) keyboardManager?.on(event: .willShow) { [tableView = tableView!] notification in + var shouldDebounceWillShow = false + } + keyboardManager?.on(event: .willShow) { [weak self, tableView = tableView!] notification in + // Don't react to first responder changes in presented view controllers + guard self?.canBecomeFirstResponder == true else { return } + willShowDebounceTimer?.invalidate() + if shouldDebounceWillShow { + willShowDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in + } + func animateTableViewInset(notification: KeyboardNotification, tableView: UITableView) { + shouldDebounceWillShow = false + willShowDebounceTimer = nil // Using superview instead of window here because in iOS 13+ a modal can change // the frame of the vc it is presented over which causes this calculation to be off. let globalTableViewFrame = tableView.convert(tableView.bounds, to: tableView.superview) @@ -1998,8 +2013,9 @@ extension ChatViewController { func showMediaGalleryFor(message: DcMsg) { let msgIds = dcContext.getChatMedia(chatId: chatId, messageType: Int32(message.type), messageType2: 0, messageType3: 0) let index = msgIds.firstIndex(of: message.id) ?? 0 - - navigationController?.pushViewController(PreviewController(dcContext: dcContext, type: .multi(msgIds, index)), animated: true) + let previewController = PreviewController(dcContext: dcContext, type: .multi(msgIds: msgIds, index: index)) + previewController.delegate = self + present(previewController, animated: true) } private func didTapAsm(msg: DcMsg, orgText: String) { @@ -2395,7 +2411,7 @@ extension ChatViewController: DraftPreviewDelegate { previewController.setEditing(true, animated: true) previewController.delegate = self } - navigationController?.pushViewController(previewController, animated: true) + present(previewController, animated: true) } } } @@ -2543,15 +2559,71 @@ extension ChatViewController: ChatContactRequestDelegate { // MARK: - QLPreviewControllerDelegate extension ChatViewController: QLPreviewControllerDelegate { func previewController(_ controller: QLPreviewController, editingModeFor previewItem: QLPreviewItem) -> QLPreviewItemEditingMode { - return .updateContents + if isDraftPreviewItem(previewItem) { + return .updateContents + } else { + return .disabled + } } func previewController(_ controller: QLPreviewController, didUpdateContentsOf previewItem: QLPreviewItem) { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.draftArea.reload(draft: self.draft) + if isDraftPreviewItem(previewItem) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.draftArea.reload(draft: self.draft) + } + } + } + + /// Since iOS 16 we can't just return a frame anymore so we return a view. + /// Since tableView is flipped we add a snapshot to the superview which we later remove. + func previewController(_ controller: QLPreviewController, transitionViewFor item: any QLPreviewItem) -> UIView? { + guard let item = item as? PreviewItem else { return nil } + + if isDraftPreviewItem(item) { + let snapshot = UIImageView(image: draftArea.mediaPreview.contentImageView.image) + snapshot.layer.opacity = 0 + snapshot.frame = draftArea.mediaPreview.contentImageView.convert( + draftArea.mediaPreview.contentImageView.frame, + to: draftArea.mainContentView + ) + // Place the snapshot below the tableView, outside of the screen so the + // preview controller animates towards where the inputAccessoryView will + // pop back in from, when firstResponder is returned. + snapshot.frame.origin.y = (tableView.superview ?? tableView).frame.maxY + tableView.superview?.addSubview(snapshot) + previewControllerTargetSnapshots.append(snapshot) + return snapshot + } else if let msgId = item.messageId, let row = messageIds.firstIndex(of: msgId) { + if let cell = tableView.cellForRow(at: IndexPath(row: row, section: 0)) as? BaseMessageCell, + let snapshot = cell.messageBackgroundContainer.snapshotView(afterScreenUpdates: false) { + if cell is ImageTextCell { // hide cell while transitioning + cell.layer.opacity = 0 + } + snapshot.layer.opacity = 0 + snapshot.clipsToBounds = true + snapshot.frame = cell.messageBackgroundContainer.globalFrame + tableView.superview?.addSubview(snapshot) + previewControllerTargetSnapshots.append(snapshot) + // TODO: Unhide others? + previewControllerTargetHiddenOriginals.append(cell) + return snapshot + } } + return nil + } + + func previewControllerDidDismiss(_ controller: QLPreviewController) { + previewControllerTargetSnapshots.forEach { $0.removeFromSuperview() } + previewControllerTargetSnapshots = [] + previewControllerTargetHiddenOriginals.forEach { $0.layer.opacity = 1 } + previewControllerTargetHiddenOriginals = [] + } + + private func isDraftPreviewItem(_ item: QLPreviewItem) -> Bool { + item.previewItemURL?.path == draft.attachment && item.previewItemURL != nil } + } // MARK: - AudioControllerDelegate @@ -2677,3 +2749,10 @@ extension ChatViewController: BackButtonUpdateable { } } } + +// TODO: Move +extension UIView { + var globalFrame: CGRect { + return self.convert(bounds, to: nil) + } +} diff --git a/deltachat-ios/Controller/FilesViewController.swift b/deltachat-ios/Controller/FilesViewController.swift index 98fbf1d4c..e94fbffde 100644 --- a/deltachat-ios/Controller/FilesViewController.swift +++ b/deltachat-ios/Controller/FilesViewController.swift @@ -261,7 +261,7 @@ extension FilesViewController { guard let index = fileMessageIds.firstIndex(of: msgId) else { return } - let previewController = PreviewController(dcContext: dcContext, type: .multi(fileMessageIds, index)) + let previewController = PreviewController(dcContext: dcContext, type: .multi(msgIds: fileMessageIds, index: index)) navigationController?.pushViewController(previewController, animated: true) } diff --git a/deltachat-ios/Controller/GalleryViewController.swift b/deltachat-ios/Controller/GalleryViewController.swift index e0506fe8d..f80ea7eb3 100644 --- a/deltachat-ios/Controller/GalleryViewController.swift +++ b/deltachat-ios/Controller/GalleryViewController.swift @@ -199,7 +199,7 @@ extension GalleryViewController: UICollectionViewDataSource, UICollectionViewDel } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let previewController = PreviewController(dcContext: dcContext, type: .multi(mediaMessageIds, indexPath.row)) + let previewController = PreviewController(dcContext: dcContext, type: .multi(msgIds: mediaMessageIds, index: indexPath.row)) previewController.delegate = self navigationController?.pushViewController(previewController, animated: true) diff --git a/deltachat-ios/Controller/PreviewController.swift b/deltachat-ios/Controller/PreviewController.swift index 36856b88f..167c6e081 100644 --- a/deltachat-ios/Controller/PreviewController.swift +++ b/deltachat-ios/Controller/PreviewController.swift @@ -5,7 +5,7 @@ import DcCore class PreviewController: QLPreviewController { enum PreviewType { case single(URL) - case multi([Int], Int) // msgIds, index + case multi(msgIds: [Int], index: Int) } let previewType: PreviewType @@ -49,7 +49,7 @@ extension PreviewController: QLPreviewControllerDataSource { return PreviewItem(url: url, title: self.customTitle) case .multi(let msgIds, _): let msg = dcContext.getMessage(id: msgIds[index]) - return PreviewItem(url: msg.fileURL, title: self.customTitle) + return PreviewItem(url: msg.fileURL, title: self.customTitle, messageId: msg.id) } } } @@ -58,9 +58,11 @@ extension PreviewController: QLPreviewControllerDataSource { class PreviewItem: NSObject, QLPreviewItem { var previewItemURL: URL? var previewItemTitle: String? + var messageId: Int? - init(url: URL?, title: String?) { + init(url: URL?, title: String?, messageId: Int? = nil) { self.previewItemURL = url self.previewItemTitle = title ?? "" + self.messageId = messageId } } diff --git a/deltachat-ios/Helper/GiveBackMyFirstResponder.swift b/deltachat-ios/Helper/GiveBackMyFirstResponder.swift index d1623c5d6..49275320b 100644 --- a/deltachat-ios/Helper/GiveBackMyFirstResponder.swift +++ b/deltachat-ios/Helper/GiveBackMyFirstResponder.swift @@ -1,4 +1,5 @@ import UIKit +import QuickLook /// https://gist.github.com/Amzd/223979ef5a06d98ef17d2d78dbd96e22 extension UIViewController { @@ -31,6 +32,14 @@ extension UIViewController { } } + /// QLPreviewController causes issues when dismissed using the swipe gesture if there was a first responder active when it was presented. + /// Issues range from freezing the previous first responder to crashing the app. + public func present(_ previewController: QLPreviewController, animated: Bool, completion: (() -> Void)? = nil) { + // QLPreviewController can not be used as child because it would not do its custom transitions + let vc = GiveBackMyFirstResponder.asChild(of: previewController) + present(vc, animated: animated, completion: completion) + } + /// In iOS 16 and below and iOS 18 the UIImagePickerController does not give back the first responder when search was used. /// This function fixes that by making the previous first responder, first responder again when the image picker is dismissed. public func present(_ imagePicker: UIImagePickerController, animated: Bool, completion: (() -> Void)? = nil) { @@ -64,6 +73,17 @@ private class GiveBackMyFirstResponder: FirstResponderRetu view.addSubview(culprit.view) culprit.didMove(toParent: self) } + + /// Returns a type erased version of the culprit view controller with a FirstResponderReturningViewController as child. + /// The type erasure prevents infinite recursion in UIViewController.present. + static func asChild(of culprit: VC) -> UIViewController { + let vc = FirstResponderReturningViewController() + culprit.addChild(vc) + culprit.view.addSubview(vc.view) + vc.view.isUserInteractionEnabled = false + vc.didMove(toParent: culprit) + return culprit + } } private class FirstResponderReturningViewController: UIViewController { diff --git a/deltachat-ios/Helper/MediaPicker.swift b/deltachat-ios/Helper/MediaPicker.swift index 5e6a52ba0..308366ff4 100644 --- a/deltachat-ios/Helper/MediaPicker.swift +++ b/deltachat-ios/Helper/MediaPicker.swift @@ -158,13 +158,30 @@ class MediaPicker: NSObject, UINavigationControllerDelegate { extension MediaPicker: UIImagePickerControllerDelegate { func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + // This async basically makes sure the "Downloading from iCloud" alert is closed and + // the keyboard is opened again if the search bar was active. This edge case causes a crash + // in the KeyInput layer and prevents the keyboard from being opened until next launch (tested on iOS 16). + // To test this: + // - Remove this asyncAfter + // - Open Chat + // - Select text field so keyboard is open + // - Attach + // - Gallery + // - Tap search bar in image picker + // - Select an image that needs to be downloaded from iCloud + // - Keyboard should come up but it does not (on iOS 16) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.handleImagePickerController(picker, didFinishPickingMediaWithInfo: info) + } + } + private func handleImagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { if let type = info[.mediaType] as? String, let mediaType = PickerMediaType(rawValue: type) { switch mediaType { case .video: if let videoUrl = info[.mediaURL] as? URL { - handleVideoUrl(url: videoUrl) + self.handleVideoUrl(url: videoUrl) } case .image: if let image = info[.editedImage] as? UIImage { From 4f00439b8299c54cab771d0c086163c86014a2fb Mon Sep 17 00:00:00 2001 From: Casper Zandbergen Date: Sun, 5 Jan 2025 15:54:12 +0100 Subject: [PATCH 2/5] better animations --- deltachat-ios/Chat/ChatViewController.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/deltachat-ios/Chat/ChatViewController.swift b/deltachat-ios/Chat/ChatViewController.swift index 4329074a5..dd206ffd8 100644 --- a/deltachat-ios/Chat/ChatViewController.swift +++ b/deltachat-ios/Chat/ChatViewController.swift @@ -261,8 +261,13 @@ class ChatViewController: UITableViewController, UITableViewDropDelegate { // Binding to the tableView will enable interactive dismissal keyboardManager?.bind(to: tableView) - keyboardManager?.on(event: .willShow) { [tableView = tableView!] notification in var shouldDebounceWillShow = false + var willShowDebounceTimer: Timer? + keyboardManager?.on(event: .didHide) { [weak self] _ in + // Debounce when input accessory view was completely hidden because when it comes + // back there is a chance that we first need to make ChatViewController firstResponder, + // and then the text field. This would cause two scroll view inset updates. + shouldDebounceWillShow = shouldDebounceWillShow || !(self?.canBecomeFirstResponder ?? true) } keyboardManager?.on(event: .willShow) { [weak self, tableView = tableView!] notification in // Don't react to first responder changes in presented view controllers @@ -270,6 +275,11 @@ class ChatViewController: UITableViewController, UITableViewDropDelegate { willShowDebounceTimer?.invalidate() if shouldDebounceWillShow { willShowDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in + animateTableViewInset(notification: notification, tableView: tableView) + } + } else { + animateTableViewInset(notification: notification, tableView: tableView) + } } func animateTableViewInset(notification: KeyboardNotification, tableView: UITableView) { shouldDebounceWillShow = false From 23c6c56d86c38a4c76bba41b011f10b8829de9a2 Mon Sep 17 00:00:00 2001 From: Casper Zandbergen Date: Sun, 5 Jan 2025 16:32:39 +0100 Subject: [PATCH 3/5] fix animation after swiping to different image in quick look --- deltachat-ios/Chat/ChatViewController.swift | 22 +++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/deltachat-ios/Chat/ChatViewController.swift b/deltachat-ios/Chat/ChatViewController.swift index dd206ffd8..2cfcd8b53 100644 --- a/deltachat-ios/Chat/ChatViewController.swift +++ b/deltachat-ios/Chat/ChatViewController.swift @@ -202,8 +202,8 @@ class ChatViewController: UITableViewController, UITableViewDropDelegate { set { _bag = newValue } } - private var previewControllerTargetSnapshots: [UIView] = [] - private var previewControllerTargetHiddenOriginals: [UIView] = [] + private var previewControllerTargetSnapshot: UIView? + private var previewControllerTargetHiddenOriginal: UIView? init(dcContext: DcContext, chatId: Int, highlightedMsg: Int? = nil) { self.dcContext = dcContext @@ -2602,11 +2602,13 @@ extension ChatViewController: QLPreviewControllerDelegate { // pop back in from, when firstResponder is returned. snapshot.frame.origin.y = (tableView.superview ?? tableView).frame.maxY tableView.superview?.addSubview(snapshot) - previewControllerTargetSnapshots.append(snapshot) + previewControllerTargetSnapshot?.removeFromSuperview() + previewControllerTargetSnapshot = snapshot return snapshot } else if let msgId = item.messageId, let row = messageIds.firstIndex(of: msgId) { if let cell = tableView.cellForRow(at: IndexPath(row: row, section: 0)) as? BaseMessageCell, let snapshot = cell.messageBackgroundContainer.snapshotView(afterScreenUpdates: false) { + previewControllerTargetHiddenOriginal?.layer.opacity = 1 if cell is ImageTextCell { // hide cell while transitioning cell.layer.opacity = 0 } @@ -2614,9 +2616,9 @@ extension ChatViewController: QLPreviewControllerDelegate { snapshot.clipsToBounds = true snapshot.frame = cell.messageBackgroundContainer.globalFrame tableView.superview?.addSubview(snapshot) - previewControllerTargetSnapshots.append(snapshot) - // TODO: Unhide others? - previewControllerTargetHiddenOriginals.append(cell) + previewControllerTargetSnapshot?.removeFromSuperview() + previewControllerTargetSnapshot = snapshot + previewControllerTargetHiddenOriginal = cell return snapshot } } @@ -2624,10 +2626,10 @@ extension ChatViewController: QLPreviewControllerDelegate { } func previewControllerDidDismiss(_ controller: QLPreviewController) { - previewControllerTargetSnapshots.forEach { $0.removeFromSuperview() } - previewControllerTargetSnapshots = [] - previewControllerTargetHiddenOriginals.forEach { $0.layer.opacity = 1 } - previewControllerTargetHiddenOriginals = [] + previewControllerTargetSnapshot?.removeFromSuperview() + previewControllerTargetSnapshot = nil + previewControllerTargetHiddenOriginal?.layer.opacity = 1 + previewControllerTargetHiddenOriginal = nil } private func isDraftPreviewItem(_ item: QLPreviewItem) -> Bool { From 4280323cb66d7e3f0669fecf352946d3063362ca Mon Sep 17 00:00:00 2001 From: Casper Zandbergen Date: Sun, 5 Jan 2025 16:43:36 +0100 Subject: [PATCH 4/5] fix quick look preview dismiss animation for images not on the screen --- deltachat-ios/Chat/ChatViewController.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/deltachat-ios/Chat/ChatViewController.swift b/deltachat-ios/Chat/ChatViewController.swift index 2cfcd8b53..908967d79 100644 --- a/deltachat-ios/Chat/ChatViewController.swift +++ b/deltachat-ios/Chat/ChatViewController.swift @@ -2606,9 +2606,15 @@ extension ChatViewController: QLPreviewControllerDelegate { previewControllerTargetSnapshot = snapshot return snapshot } else if let msgId = item.messageId, let row = messageIds.firstIndex(of: msgId) { - if let cell = tableView.cellForRow(at: IndexPath(row: row, section: 0)) as? BaseMessageCell, + previewControllerTargetHiddenOriginal?.layer.opacity = 1 + previewControllerTargetSnapshot?.removeFromSuperview() + // Scroll to the message that will be dismissed + let indexPath = IndexPath(row: row, section: 0) + if tableView.indexPathsForVisibleRows?.contains(indexPath) == false { + tableView.scrollToRow(at: indexPath, at: .none, animated: false) + } + if let cell = tableView.cellForRow(at: indexPath) as? BaseMessageCell, let snapshot = cell.messageBackgroundContainer.snapshotView(afterScreenUpdates: false) { - previewControllerTargetHiddenOriginal?.layer.opacity = 1 if cell is ImageTextCell { // hide cell while transitioning cell.layer.opacity = 0 } @@ -2616,7 +2622,6 @@ extension ChatViewController: QLPreviewControllerDelegate { snapshot.clipsToBounds = true snapshot.frame = cell.messageBackgroundContainer.globalFrame tableView.superview?.addSubview(snapshot) - previewControllerTargetSnapshot?.removeFromSuperview() previewControllerTargetSnapshot = snapshot previewControllerTargetHiddenOriginal = cell return snapshot From 33cc43821f7eba8bc59e0075eaeb41191c841360 Mon Sep 17 00:00:00 2001 From: Casper Zandbergen Date: Sun, 5 Jan 2025 16:53:58 +0100 Subject: [PATCH 5/5] prevent wrong image in reused cells --- deltachat-ios/Chat/ChatViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deltachat-ios/Chat/ChatViewController.swift b/deltachat-ios/Chat/ChatViewController.swift index 908967d79..9f163c095 100644 --- a/deltachat-ios/Chat/ChatViewController.swift +++ b/deltachat-ios/Chat/ChatViewController.swift @@ -2614,7 +2614,7 @@ extension ChatViewController: QLPreviewControllerDelegate { tableView.scrollToRow(at: indexPath, at: .none, animated: false) } if let cell = tableView.cellForRow(at: indexPath) as? BaseMessageCell, - let snapshot = cell.messageBackgroundContainer.snapshotView(afterScreenUpdates: false) { + let snapshot = cell.messageBackgroundContainer.snapshotView(afterScreenUpdates: true) { if cell is ImageTextCell { // hide cell while transitioning cell.layer.opacity = 0 }