Path: blob/main/Signal/src/ViewControllers/MediaGallery/MediaPageViewController.swift
1 views
//
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
import UIKit
class MediaPageViewController: UIPageViewController {
private lazy var mediaInteractiveDismiss = MediaInteractiveDismiss(targetViewController: self)
private let isShowingSingleMessage: Bool
let mediaGallery: MediaGallery
let spoilerState: SpoilerRenderState
private let initialGalleryItem: MediaGalleryItem
convenience init?(
initialMediaAttachment: ReferencedAttachment,
thread: TSThread,
spoilerState: SpoilerRenderState,
showingSingleMessage: Bool = false,
) {
self.init(
initialMediaAttachment: initialMediaAttachment,
mediaGallery: MediaGallery(thread: thread, mediaCategory: .photoVideo, spoilerState: spoilerState),
spoilerState: spoilerState,
showingSingleMessage: showingSingleMessage,
)
}
init?(
initialMediaAttachment: ReferencedAttachment,
mediaGallery: MediaGallery,
spoilerState: SpoilerRenderState,
showingSingleMessage: Bool = false,
) {
self.mediaGallery = mediaGallery
self.spoilerState = spoilerState
self.isShowingSingleMessage = showingSingleMessage
Logger.info("will ensureLoadedForDetailView")
guard let initialItem = mediaGallery.ensureLoadedForDetailView(focusedAttachment: initialMediaAttachment) else {
owsFailDebug("unexpectedly failed to build initialDetailItem.")
return nil
}
Logger.info("ensureLoadedForDetailView done")
self.initialGalleryItem = initialItem
super.init(
transitionStyle: .scroll,
navigationOrientation: .horizontal,
options: [.interPageSpacing: 20],
)
extendedLayoutIncludesOpaqueBars = true
modalPresentationStyle = .overFullScreen
modalPresentationCapturesStatusBarAppearance = true
dataSource = self
delegate = self
transitioningDelegate = self
}
@available(*, unavailable, message: "Unimplemented")
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Controls
// Top Bar
private lazy var topPanel: UIView = {
let view = UIView()
view.preservesSuperviewLayoutMargins = true
// iOS 26: Transparent bar with glass backgrounds for controls.
// Pre-iOS 26: blur background.
if #unavailable(iOS 26) {
let blurBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
blurBackgroundView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(blurBackgroundView)
NSLayoutConstraint.activate([
blurBackgroundView.topAnchor.constraint(equalTo: view.topAnchor),
blurBackgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
blurBackgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
blurBackgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
return view
}()
private var navigationBarVerticalPositionConstraint: NSLayoutConstraint?
// Bottom Bar
private lazy var bottomMediaPanel = MediaControlPanelView(
mediaGallery: mediaGallery,
delegate: self,
spoilerState: spoilerState,
)
// MARK: UIViewController
override var preferredStatusBarStyle: UIStatusBarStyle {
guard #unavailable(iOS 26) else {
return super.preferredStatusBarStyle
}
if Theme.isDarkThemeEnabled {
return .lightContent
}
let useDarkContentStatusBar: Bool
if mediaInteractiveDismiss.interactionInProgress {
useDarkContentStatusBar = true
} else if isBeingDismissed, let transitionCoordinator {
useDarkContentStatusBar = !transitionCoordinator.isCancelled
} else {
useDarkContentStatusBar = false
}
if useDarkContentStatusBar {
return .darkContent
}
return .lightContent
}
override var prefersStatusBarHidden: Bool {
return shouldHideStatusBar
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return .none
}
override func viewDidLoad() {
super.viewDidLoad()
if #unavailable(iOS 26) {
overrideUserInterfaceStyle = .dark
}
view.backgroundColor = .Signal.background
mediaInteractiveDismiss.addGestureRecognizer(to: view)
navigationItem.titleView = headerView
// Top panel
// Use UINavigation bar to ensure position of the < back button matches exactly of one in the presenting VC.
let navigationBar = UINavigationBar()
navigationBar.delegate = self
navigationBar.tintColor = .Signal.label
navigationBar.isUserInteractionEnabled = true
let appearance = UINavigationBarAppearance()
appearance.configureWithTransparentBackground()
navigationBar.standardAppearance = appearance
navigationBar.compactAppearance = appearance
navigationBar.scrollEdgeAppearance = appearance
navigationBar.setItems([UINavigationItem(title: ""), navigationItem], animated: false)
navigationBar.translatesAutoresizingMaskIntoConstraints = false
topPanel.addSubview(navigationBar)
// See `viewSafeAreaInsetsDidChange` why this is needed.
navigationBarVerticalPositionConstraint = navigationBar.topAnchor.constraint(equalTo: topPanel.topAnchor)
NSLayoutConstraint.activate([
navigationBarVerticalPositionConstraint!,
navigationBar.bottomAnchor.constraint(equalTo: topPanel.bottomAnchor),
])
// On iOS 26 navigation bar extends all the way to left and right screen edges even in landscape.
if #available(iOS 26, *) {
NSLayoutConstraint.activate([
navigationBar.leadingAnchor.constraint(equalTo: topPanel.leadingAnchor),
navigationBar.trailingAnchor.constraint(equalTo: topPanel.trailingAnchor),
])
} else {
NSLayoutConstraint.activate([
navigationBar.leadingAnchor.constraint(equalTo: topPanel.safeAreaLayoutGuide.leadingAnchor),
navigationBar.trailingAnchor.constraint(equalTo: topPanel.safeAreaLayoutGuide.trailingAnchor),
])
}
// Add top panel and constrain it to view's leading, top and trailing edges.
// Navigation bar (set up above) determines this panel's height.
topPanel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(topPanel)
NSLayoutConstraint.activate([
topPanel.topAnchor.constraint(equalTo: view.topAnchor),
topPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
topPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
// Bottom panel
bottomMediaPanel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(bottomMediaPanel)
NSLayoutConstraint.activate([
bottomMediaPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
bottomMediaPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor),
bottomMediaPanel.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
updateControlsForCurrentOrientation()
// Load initial page and update all UI to reflect it.
setCurrentItem(initialGalleryItem, direction: .forward, shouldAutoPlayVideo: true, animated: false)
mediaGallery.addDelegate(self)
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
setNeedsStatusBarAppearanceUpdate()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.verticalSizeClass != previousTraitCollection?.verticalSizeClass {
updateControlsForCurrentOrientation()
}
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
if let navigationBarVerticalPositionConstraint {
// On iPhones with a Dynamic Island standard position of a navigation bar is bottom of the status bar,
// which is ~5 dp smaller than the top safe area inset (https://useyourloaf.com/blog/iphone-14-screen-sizes/) .
// Since it is not possible to constrain top edge of our manually maintained navigation bar to that position
// the workaround is to detect when top safe area inset is larger than the status bar height and adjust as needed.
var topInset = view.safeAreaInsets.top
if
#unavailable(iOS 26),
let statusBarHeight = view.window?.windowScene?.statusBarManager?.statusBarFrame.height,
statusBarHeight < topInset
{
topInset = statusBarHeight
if #available(iOS 18, *) {
topInset += (2 + .hairlineWidth)
} else if #available(iOS 16, *) {
topInset -= .hairlineWidth
}
}
// On iOS 26 in landscape the navigation bar is offset 24 dp from the screen top edge.
if #available(iOS 26, *), topInset.isZero {
topInset = 24
}
navigationBarVerticalPositionConstraint.constant = topInset
}
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
cachedPages.removeAll()
}
// MARK: Paging
private var cachedPages: [MediaGalleryItem: MediaItemViewController] = [:]
private func buildGalleryPage(galleryItem: MediaGalleryItem) -> MediaItemViewController {
if let cachedPage = cachedPages[galleryItem] {
Logger.debug("cache hit.")
return cachedPage
}
Logger.debug("cache miss.")
let viewController = MediaItemViewController(galleryItem: galleryItem)
viewController.delegate = self
cachedPages[galleryItem] = viewController
return viewController
}
private var currentViewController: MediaItemViewController? {
let viewController = viewControllers?.first as? MediaItemViewController
owsAssertBeta(viewController != nil)
return viewController
}
private var currentItem: MediaGalleryItem! {
return currentViewController?.galleryItem
}
private var currentPageSwipeDirection: UIPageViewController.NavigationDirection = .forward
private func setCurrentItem(
_ item: MediaGalleryItem,
direction: UIPageViewController.NavigationDirection,
shouldAutoPlayVideo: Bool = false,
animated: Bool,
) {
if let previousPage = viewControllers?.first as? MediaItemViewController {
previousPage.videoPlaybackStatusObserver = nil
previousPage.zoomOut(animated: false)
previousPage.stopVideoIfPlaying()
}
let mediaPage = buildGalleryPage(galleryItem: item)
mediaPage.shouldAutoPlayVideo = true
setViewControllers([mediaPage], direction: direction, animated: animated) { _ in
self.didTransitionToNewPage(animated: animated, direction: direction)
}
}
private func didTransitionToNewPage(animated: Bool, direction: UIPageViewController.NavigationDirection) {
guard let currentViewController else {
owsFailBeta("No MediaItemViewController")
return
}
bottomMediaPanel.configureWithMediaItem(
currentViewController.galleryItem,
videoPlayer: currentViewController.videoPlayer,
transitionDirection: direction,
animated: animated,
)
updateScreenTitle(using: currentViewController.galleryItem)
currentViewController.videoPlaybackStatusObserver = bottomMediaPanel
showOrHideTopAndBottomPanelsAsNecessary(animated: animated)
}
// MARK: Show / hide toolbars
private var _shouldHideToolbars: Bool = false
private var shouldHideToolbars: Bool {
get { _shouldHideToolbars }
set { setShouldHideToolbars(newValue, animated: false) }
}
private func setShouldHideToolbars(_ shouldHide: Bool, animated: Bool = false) {
_shouldHideToolbars = shouldHide
showOrHideTopAndBottomPanelsAsNecessary(animated: animated)
setNeedsStatusBarAppearanceUpdate()
}
private func showOrHideTopAndBottomPanelsAsNecessary(animated: Bool) {
topPanel.setIsHidden(shouldHideToolbars, animated: animated)
bottomMediaPanel.setIsHidden(shouldHideToolbars || bottomMediaPanel.shouldBeHidden, animated: animated)
if #available(iOS 26, *) {
let targetColor = shouldHideToolbars ? UIColor.black : UIColor.Signal.background
if animated {
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut)
animator.addAnimations {
self.view.backgroundColor = targetColor
}
animator.startAnimation()
} else {
view.backgroundColor = targetColor
}
}
}
private var shouldHideStatusBar: Bool {
guard !UIDevice.current.isIPad else { return shouldHideToolbars }
return shouldHideToolbars || traitCollection.verticalSizeClass == .compact
}
private func updateControlsForCurrentOrientation() {
// Bottom bar might be hidden while in landscape and visible in portrait, for the same media.
showOrHideTopAndBottomPanelsAsNecessary(animated: false)
if traitCollection.verticalSizeClass == .compact {
// Order of buttons is reversed: first button in array is the outermost in the navbar.
navigationItem.rightBarButtonItems = [contextMenuBarButton, barButtonForwardMedia, barButtonShareMedia]
} else {
navigationItem.rightBarButtonItems = [contextMenuBarButton]
}
}
// MARK: Context Menu
private lazy var contextMenuBarButton: UIBarButtonItem = {
let buttonImageName: String = if #available(iOS 26, *) { "more" } else { "more-circle" }
let contextMenuBarButton = UIBarButtonItem(
image: UIImage(named: buttonImageName),
landscapeImagePhone: UIImage(named: buttonImageName + "-20"),
style: .plain,
target: nil,
action: nil,
)
contextMenuBarButton.menu = UIMenu(
title: "",
children: [
// TODO: Video Playback Speed
// TODO: Edit
UIAction(
title: OWSLocalizedString(
"MEDIA_VIEWER_SAVE_MEDIA_ACTION",
comment: "Context menu item in media viewer. Refers to saving currently displayed photo/video to the Photos app.",
),
image: Theme.iconImage(.contextMenuSave),
handler: { [weak self] _ in
self?.saveCurrentMediaToPhotos()
},
),
UIAction(
title: OWSLocalizedString(
"MEDIA_VIEWER_GO_TO_MESSAGE_ACTION",
comment: "Context menu item in media viewer. Refers to scrolling the conversation to the currently displayed photo/video.",
),
image: Theme.iconImage(.buttonMessage),
handler: { [weak self] _ in
self?.presentConversationForCurrentMedia()
},
),
UIAction(
title: OWSLocalizedString(
"MEDIA_VIEWER_DELETE_MEDIA_ACTION",
comment: "Context menu item in media viewer. Refers to deleting currently displayed photo/video.",
),
image: Theme.iconImage(.contextMenuDelete),
attributes: .destructive,
handler: { [weak self] _ in
self?.deleteCurrentMedia()
},
),
],
)
return contextMenuBarButton
}()
// MARK: Bar Buttons
private lazy var barButtonShareMedia = UIBarButtonItem(
image: Theme.iconImage(.buttonShare),
landscapeImagePhone: UIImage(imageLiteralResourceName: "share-20"),
style: .plain,
target: self,
action: #selector(didPressShare),
)
private lazy var barButtonForwardMedia = UIBarButtonItem(
image: Theme.iconImage(.buttonForward),
landscapeImagePhone: UIImage(imageLiteralResourceName: "forward-20"),
style: .plain,
target: self,
action: #selector(didPressForward),
)
// MARK: Helpers
private func dismissSelf(animated isAnimated: Bool, completion: (() -> Void)? = nil) {
guard let currentViewController else { return }
// Swapping mediaView for presentationView will be perceptible if we're not zoomed out all the way.
currentViewController.zoomOut(animated: true)
currentViewController.stopVideoIfPlaying()
navigationController?.setNavigationBarHidden(false, animated: false)
dismiss(animated: isAnimated, completion: completion)
}
// MARK: Actions
@objc
private func didTapBackButton(_ sender: Any) {
Logger.debug("")
dismissSelf(animated: true)
}
@objc
private func didPressShare(_ sender: Any) {
shareCurrentMedia(fromNavigationBar: true)
}
/// Forwards all media from the message containing the currently gallery
/// item.
///
/// Skips any media that we do not have downloaded.
@objc
private func didPressForward(_ sender: Any) {
forwardCurrentMedia()
}
private func forwardCurrentMedia() {
let messageForCurrentItem = currentItem.message
let mediaAttachments: [ReferencedAttachment] = SSKEnvironment.shared.databaseStorageRef.read { transaction in
guard let rowId = messageForCurrentItem.sqliteRowId else { return [] }
return DependenciesBridge.shared.attachmentStore
.fetchReferencedAttachments(for: .messageBodyAttachment(messageRowId: rowId), tx: transaction)
}
let mediaAttachmentStreams: [ReferencedAttachmentStream] = mediaAttachments.compactMap { attachment in
guard let attachmentStream = attachment.asReferencedStream else {
// Our current media item should always be an attachment
// stream (downloaded). However, we can't guarantee that the
// same is true for other media in the message to forward. For
// example, another piece of media in this message may have
// failed to download.
//
// If so, we should continue trying to forward the ones we can.
Logger.warn("Skipping attachment that is not an attachment stream. Did this attachment fail to download?")
return nil
}
return attachmentStream
}
let mediaCount = mediaAttachmentStreams.count
switch mediaCount {
case 0:
owsFail("We should always have at least one attachment stream, for the current item.")
case 1:
ForwardMessageViewController.present(
forAttachmentStreams: mediaAttachmentStreams,
fromMessage: messageForCurrentItem,
from: self,
delegate: self,
)
default:
// If we are forwarding multiple items, warn the user first.
let titleFormatString = OWSLocalizedString(
"MEDIA_PAGE_FORWARD_MEDIA_CONFIRM_TITLE_%d",
tableName: "PluralAware",
comment: "Text confirming the user wants to forward media. Embeds {{ %1$@ the number of media to be forwarded }}.",
)
OWSActionSheets.showConfirmationAlert(
message: OWSLocalizedString(
"MEDIA_PAGE_FORWARD_MEDIA_CONFIRM_MESSAGE",
comment: "Text explaining that the user will forward all media from a message.",
),
proceedTitle: String.localizedStringWithFormat(
titleFormatString,
mediaCount,
),
proceedAction: { [weak self] _ in
guard let self else { return }
ForwardMessageViewController.present(
forAttachmentStreams: mediaAttachmentStreams,
fromMessage: messageForCurrentItem,
from: self,
delegate: self,
)
},
)
}
}
private func shareCurrentMedia(fromNavigationBar: Bool) {
guard let currentViewController else { return }
guard
let attachmentStream = (try? [currentViewController.galleryItem.attachmentStream].asShareableAttachments())?.first
else {
return
}
let sender = fromNavigationBar ? barButtonShareMedia : bottomMediaPanel
AttachmentSharing.showShareUI(for: attachmentStream, sender: sender)
}
// MARK: -
private func saveCurrentMediaToPhotos() {
guard let mediaItem = currentItem else { return }
AttachmentSaving.saveToPhotoLibrary(
referencedAttachmentStreams: [mediaItem.attachmentStream],
)
}
private func presentConversationForCurrentMedia() {
guard let mediaItem = currentItem else { return }
dismissSelf(animated: true) {
SignalApp.shared.presentConversationForThread(
threadUniqueId: mediaItem.message.uniqueThreadId,
focusMessageId: mediaItem.message.uniqueId,
animated: true,
)
}
}
private func deleteCurrentMedia() {
guard let mediaItem = currentItem else { return }
let actionSheet = ActionSheetController(title: nil, message: nil)
let deleteAction = ActionSheetAction(
title: CommonStrings.deleteButton,
style: .destructive,
) { _ in
self.mediaGallery.delete(items: [mediaItem], initiatedBy: self)
}
actionSheet.addAction(OWSActionSheets.cancelAction)
actionSheet.addAction(deleteAction)
presentActionSheet(actionSheet)
}
// MARK: Dynamic Header
private func senderName(from message: TSMessage) -> String {
switch message {
case let incomingMessage as TSIncomingMessage:
return SSKEnvironment.shared.databaseStorageRef.read { tx in
return SSKEnvironment.shared.contactManagerRef.displayName(for: incomingMessage.authorAddress, tx: tx).resolvedValue()
}
case is TSOutgoingMessage:
return CommonStrings.you
default:
owsFailDebug("Unknown message type: \(type(of: message))")
return ""
}
}
private lazy var dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter
}()
private lazy var headerNameLabel: UILabel = {
let label = UILabel()
label.textAlignment = .center
label.textColor = .Signal.label
if #available(iOS 26, *) {
label.font = .dynamicTypeSubheadlineClamped.semibold()
// "semibold" fonts aren't dynamic anymore - have to track changes manually.
label.registerForTraitChanges([UITraitPreferredContentSizeCategory.self]) { (label: UILabel, _) in
label.font = .dynamicTypeSubheadlineClamped.semibold()
}
} else {
label.font = UIFont.regularFont(ofSize: 15)
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.8
}
return label
}()
private lazy var headerDateLabel: UILabel = {
let label = UILabel()
label.textAlignment = .center
label.textColor = .Signal.label
if #available(iOS 26, *) {
label.font = .dynamicTypeCaption1Clamped
label.adjustsFontForContentSizeCategory = true
} else {
label.font = .regularFont(ofSize: 11)
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.8
}
return label
}()
private lazy var headerView: UIView = {
let stackView = UIStackView(arrangedSubviews: [headerNameLabel, headerDateLabel])
stackView.axis = .vertical
stackView.translatesAutoresizingMaskIntoConstraints = false
let containerView = UIView()
if #available(iOS 26, *) {
// Can't return `glassEffectView` as `headerView` because UINavigationBar stretches it to fill width.
let glassEffect = UIGlassEffect(style: .regular)
glassEffect.isInteractive = true
let glassEffectView = UIVisualEffectView(effect: glassEffect)
glassEffectView.cornerConfiguration = .capsule()
glassEffectView.translatesAutoresizingMaskIntoConstraints = false
glassEffectView.contentView.addSubview(stackView)
let contentInset = UIEdgeInsets(hMargin: 24, vMargin: 4)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(greaterThanOrEqualTo: glassEffectView.topAnchor, constant: contentInset.top),
stackView.centerYAnchor.constraint(equalTo: glassEffectView.centerYAnchor),
stackView.leadingAnchor.constraint(equalTo: glassEffectView.leadingAnchor, constant: contentInset.leading),
stackView.trailingAnchor.constraint(equalTo: glassEffectView.trailingAnchor, constant: -contentInset.trailing),
])
containerView.addSubview(glassEffectView)
NSLayoutConstraint.activate([
glassEffectView.topAnchor.constraint(equalTo: containerView.topAnchor),
glassEffectView.leadingAnchor.constraint(greaterThanOrEqualTo: containerView.leadingAnchor),
glassEffectView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
glassEffectView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
// On iOS 26 navigation bar is transparent and can accomodate `titleView` of any height.
// Set minimum height to default 44pts thus allowing it to grow with font size.
containerView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
} else {
containerView.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(greaterThanOrEqualTo: containerView.topAnchor),
stackView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
stackView.leadingAnchor.constraint(greaterThanOrEqualTo: containerView.leadingAnchor),
stackView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
])
}
return containerView
}()
private func updateScreenTitle(using mediaItem: MediaGalleryItem) {
headerNameLabel.text = senderName(from: mediaItem.message)
// use sent date
let date = Date(timeIntervalSince1970: Double(mediaItem.message.timestamp) / 1000)
headerDateLabel.text = dateFormatter.string(from: date)
}
}
extension MediaPageViewController: UIPageViewControllerDelegate {
func pageViewController(
_ pageViewController: UIPageViewController,
willTransitionTo pendingViewControllers: [UIViewController],
) {
guard
let currentPage = pageViewController.viewControllers?.first as? MediaItemViewController,
let newPage = pendingViewControllers.first as? MediaItemViewController
else {
return
}
if currentPage.galleryItem.orderingKey < newPage.galleryItem.orderingKey {
currentPageSwipeDirection = .forward
} else {
currentPageSwipeDirection = .reverse
}
}
func pageViewController(
_ pageViewController: UIPageViewController,
didFinishAnimating finished: Bool,
previousViewControllers: [UIViewController],
transitionCompleted: Bool,
) {
if let previousPage = previousViewControllers.first as? MediaItemViewController {
previousPage.zoomOut(animated: false)
previousPage.stopVideoIfPlaying()
previousPage.videoPlaybackStatusObserver = nil
}
if transitionCompleted {
didTransitionToNewPage(animated: true, direction: currentPageSwipeDirection)
}
}
}
extension MediaPageViewController: UIPageViewControllerDataSource {
private func itemIsAllowed(_ item: MediaGalleryItem) -> Bool {
// Normally, we can show any media item, but if we're limited
// to showing a single message, don't page beyond that message
return !isShowingSingleMessage || currentItem.message == item.message
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
Logger.debug("")
guard let currentPage = viewController as? MediaItemViewController else {
owsFailDebug("unexpected viewController: \(viewController)")
return nil
}
guard let precedingItem = mediaGallery.galleryItem(before: currentPage.galleryItem), itemIsAllowed(precedingItem) else {
return nil
}
return buildGalleryPage(galleryItem: precedingItem)
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
Logger.debug("")
guard let currentPage = viewController as? MediaItemViewController else {
owsFailDebug("unexpected viewController: \(viewController)")
return nil
}
guard let nextItem = mediaGallery.galleryItem(after: currentPage.galleryItem), itemIsAllowed(nextItem) else {
// no more pages
return nil
}
return buildGalleryPage(galleryItem: nextItem)
}
}
extension MediaPageViewController: InteractivelyDismissableViewController {
func performInteractiveDismissal(animated: Bool) {
dismissSelf(animated: true)
}
}
extension MediaPageViewController: MediaGalleryDelegate {
func mediaGallery(_ mediaGallery: MediaGallery, applyUpdate update: MediaGallery.Update) {
Logger.debug("")
}
func mediaGallery(_ mediaGallery: MediaGallery, willDelete items: [MediaGalleryItem], initiatedBy: AnyObject) {
Logger.debug("")
guard items.contains(currentItem) else {
Logger.debug("irrelevant item")
return
}
// If we setCurrentItem with (animated: true) while this VC is in the background, then
// the next/previous cache isn't expired, and we're able to swipe back to the just-deleted vc.
// So to get the correct behavior, we should only animate these transitions when this
// vc is in the foreground
let isAnimated = initiatedBy === self
if isShowingSingleMessage {
// In message details, which doesn't use the slider, so don't swap pages.
} else if let nextItem = mediaGallery.galleryItem(after: currentItem) {
setCurrentItem(nextItem, direction: .forward, animated: isAnimated)
} else if let previousItem = mediaGallery.galleryItem(before: currentItem) {
setCurrentItem(previousItem, direction: .reverse, animated: isAnimated)
} else {
// else we deleted the last piece of media, return to the conversation view
dismissSelf(animated: true)
}
}
func mediaGalleryDidDeleteItem(_ mediaGallery: MediaGallery) {
// Either this is an internal deletion, in which case willDelete would have been called already,
// or it's an external deletion, in which case mediaGalleryDidReloadItems would have been called already.
}
func mediaGalleryDidReloadItems(_ mediaGallery: MediaGallery) {
didReloadAllSectionsInMediaGallery(mediaGallery)
}
func didAddSectionInMediaGallery(_ mediaGallery: MediaGallery) {
// Does not affect the current item.
}
func didReloadAllSectionsInMediaGallery(_ mediaGallery: MediaGallery) {
let attachment = currentItem.attachmentStream
guard let reloadedItem = mediaGallery.ensureLoadedForDetailView(focusedAttachment: attachment) else {
// Assume the item was deleted.
dismissSelf(animated: true)
return
}
setCurrentItem(reloadedItem, direction: .forward, animated: false)
}
func mediaGalleryShouldDeferUpdate(_ mediaGallery: MediaGallery) -> Bool {
return false
}
}
extension MediaPageViewController: MediaItemViewControllerDelegate {
func mediaItemViewControllerDidTapMedia(_ viewController: MediaItemViewController) {
setShouldHideToolbars(!shouldHideToolbars, animated: true)
}
func mediaItemViewControllerWillBeginZooming(_ viewController: MediaItemViewController) {
setShouldHideToolbars(true, animated: true)
}
func mediaItemViewControllerFullyZoomedOut(_ viewController: MediaItemViewController) {
setShouldHideToolbars(false, animated: true)
}
}
extension MediaGalleryItem: GalleryRailItem {
public func buildRailItemView() -> UIView {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.image = thumbnailImageSync()
return imageView
}
}
extension MediaGalleryAlbum: GalleryRailItemProvider {
var railItems: [GalleryRailItem] {
return self.items
}
}
extension MediaPageViewController: MediaControlPanelDelegate {
func mediaControlPanelDidRequestForwardMedia(_ panel: MediaControlPanelView) {
forwardCurrentMedia()
}
func mediaControlPanelDidRequestShareMedia(_ panel: MediaControlPanelView) {
shareCurrentMedia(fromNavigationBar: false)
}
func galleryRailView(_ galleryRailView: GalleryRailView, didTapItem imageRailItem: GalleryRailItem) {
guard let targetItem = imageRailItem as? MediaGalleryItem else {
owsFailDebug("unexpected imageRailItem: \(imageRailItem)")
return
}
let direction: UIPageViewController.NavigationDirection
direction = currentItem.albumIndex < targetItem.albumIndex ? .forward : .reverse
setCurrentItem(targetItem, direction: direction, animated: true)
}
}
extension MediaPageViewController: MediaPresentationContextProvider {
func mediaPresentationContext(item: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? {
guard let mediaView = currentViewController?.mediaView else { return nil }
guard nil != mediaView.superview else {
owsFailDebug("superview was unexpectedly nil")
return nil
}
view.layoutIfNeeded()
let backgroundColor: UIColor = if #available(iOS 26, *) { .Signal.background } else { .black }
return MediaPresentationContext(
mediaView: mediaView,
presentationFrame: mediaView.frame,
backgroundColor: backgroundColor,
)
}
func mediaWillPresent(toContext: MediaPresentationContext) {
view.backgroundColor = .clear
}
func mediaDidPresent(toContext: MediaPresentationContext) {
view.backgroundColor = .Signal.background
}
func mediaWillDismiss(fromContext: MediaPresentationContext) {
view.backgroundColor = .clear
}
func mediaDidDismiss(fromContext: MediaPresentationContext) {
view.backgroundColor = .Signal.background
}
}
extension MediaPageViewController: UIViewControllerTransitioningDelegate {
func animationController(
forPresented presented: UIViewController,
presenting: UIViewController,
source: UIViewController,
) -> UIViewControllerAnimatedTransitioning? {
guard self == presented else {
owsFailDebug("unexpected presented: \(presented)")
return nil
}
return MediaZoomAnimationController(galleryItem: currentItem)
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
guard self == dismissed else {
owsFailDebug("unexpected dismissed: \(dismissed)")
return nil
}
let animationController = MediaDismissAnimationController(
galleryItem: currentItem,
interactionController: mediaInteractiveDismiss,
)
mediaInteractiveDismiss.interactiveDismissDelegate = animationController
return animationController
}
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
guard
let animationController = animator as? MediaDismissAnimationController,
animationController.interactionController.interactionInProgress
else {
return nil
}
return animationController.interactionController
}
}
extension MediaPageViewController: ForwardMessageDelegate {
func forwardMessageFlowDidComplete(items: [ForwardMessageItem], recipientThreads: [TSThread]) {
dismiss(animated: true) {
ForwardMessageViewController.finalizeForward(
items: items,
recipientThreads: recipientThreads,
fromViewController: self,
)
}
}
func forwardMessageFlowDidCancel() {
dismiss(animated: true)
}
}
extension MediaPageViewController: UINavigationBarDelegate {
func navigationBar(_ navigationBar: UINavigationBar, shouldPop item: UINavigationItem) -> Bool {
dismissSelf(animated: true)
return false
}
func navigationBar(_ navigationBar: UINavigationBar, didPop item: UINavigationItem) {
dismissSelf(animated: true)
}
}