Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

QuickLook swipe down and less message motion when coming back from GiveBackMyFirstResponder #2482

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 104 additions & 8 deletions deltachat-ios/Chat/ChatViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ class ChatViewController: UITableViewController, UITableViewDropDelegate {
set { _bag = newValue }
}

private var previewControllerTargetSnapshot: UIView?
private var previewControllerTargetHiddenOriginal: UIView?

init(dcContext: DcContext, chatId: Int, highlightedMsg: Int? = nil) {
self.dcContext = dcContext
self.chatId = chatId
Expand Down Expand Up @@ -258,7 +261,29 @@ 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
guard self?.canBecomeFirstResponder == true else { return }
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
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)
Expand Down Expand Up @@ -1998,8 +2023,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) {
Expand Down Expand Up @@ -2395,7 +2421,7 @@ extension ChatViewController: DraftPreviewDelegate {
previewController.setEditing(true, animated: true)
previewController.delegate = self
}
navigationController?.pushViewController(previewController, animated: true)
present(previewController, animated: true)
}
}
}
Expand Down Expand Up @@ -2543,15 +2569,78 @@ 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)
previewControllerTargetSnapshot?.removeFromSuperview()
previewControllerTargetSnapshot = snapshot
return snapshot
} else if let msgId = item.messageId, let row = messageIds.firstIndex(of: msgId) {
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: true) {
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)
previewControllerTargetSnapshot = snapshot
previewControllerTargetHiddenOriginal = cell
return snapshot
}
}
return nil
}

func previewControllerDidDismiss(_ controller: QLPreviewController) {
previewControllerTargetSnapshot?.removeFromSuperview()
previewControllerTargetSnapshot = nil
previewControllerTargetHiddenOriginal?.layer.opacity = 1
previewControllerTargetHiddenOriginal = nil
}

private func isDraftPreviewItem(_ item: QLPreviewItem) -> Bool {
item.previewItemURL?.path == draft.attachment && item.previewItemURL != nil
}

}

// MARK: - AudioControllerDelegate
Expand Down Expand Up @@ -2677,3 +2766,10 @@ extension ChatViewController: BackButtonUpdateable {
}
}
}

// TODO: Move
extension UIView {
var globalFrame: CGRect {
return self.convert(bounds, to: nil)
}
}
2 changes: 1 addition & 1 deletion deltachat-ios/Controller/FilesViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
2 changes: 1 addition & 1 deletion deltachat-ios/Controller/GalleryViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 5 additions & 3 deletions deltachat-ios/Controller/PreviewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
Expand All @@ -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
}
}
20 changes: 20 additions & 0 deletions deltachat-ios/Helper/GiveBackMyFirstResponder.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import UIKit
import QuickLook

/// https://gist.github.com/Amzd/223979ef5a06d98ef17d2d78dbd96e22
extension UIViewController {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -64,6 +73,17 @@ private class GiveBackMyFirstResponder<VC: UIViewController>: 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 {
Expand Down
19 changes: 18 additions & 1 deletion deltachat-ios/Helper/MediaPicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down