Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/Photos/AvatarViewController.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
import SignalUI
import UIKit

class AvatarViewController: OWSViewController, InteractivelyDismissableViewController {
    private lazy var interactiveDismissal = MediaInteractiveDismiss(targetViewController: self)
    let avatarImage: UIImage

    var maxAvatarPointSize: CGSize {
        let currentScale = avatarImage.scale
        let desiredScale = UIScreen.main.scale
        let factor = currentScale / desiredScale
        return CGSize.scale(avatarImage.size, factor: factor)
    }

    private let imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFit
        return imageView
    }()

    private let circleView = CircleView()

    private var navigationBarTopLayoutConstraint: NSLayoutConstraint?

    private var backgroundColor: UIColor {
        // Not using UIColor.Signal.background here because this VC is presented modally
        // but we need `base` background color and not `elevated`.
        UIColor(
            light: Theme.lightThemeBackgroundColor,
            dark: Theme.darkThemeBackgroundColor,
        )
    }

    init?(thread: TSThread, renderLocalUserAsNoteToSelf: Bool, readTx: DBReadTransaction) {
        let localUserDisplayMode: LocalUserDisplayMode = (
            renderLocalUserAsNoteToSelf
                ? .noteToSelf
                : .asUser,
        )
        guard
            let avatarImage = SSKEnvironment.shared.avatarBuilderRef.avatarImage(
                forThread: thread,
                diameterPoints: UInt(UIScreen.main.bounds.size.smallerAxis),
                localUserDisplayMode: localUserDisplayMode,
                transaction: readTx,
            ) else { return nil }

        self.avatarImage = avatarImage
        super.init()

        modalPresentationStyle = .custom
        transitioningDelegate = self
    }

    init?(address: SignalServiceAddress, renderLocalUserAsNoteToSelf: Bool, readTx: DBReadTransaction) {
        let avatarImage = SSKEnvironment.shared.avatarBuilderRef.avatarImage(
            forAddress: address,
            diameterPoints: UInt(UIScreen.main.bounds.size.smallerAxis),
            localUserDisplayMode: renderLocalUserAsNoteToSelf ? .noteToSelf : .asUser,
            transaction: readTx,
        )
        guard let avatarImage else {
            return nil
        }

        self.avatarImage = avatarImage
        super.init()

        modalPresentationStyle = .custom
        transitioningDelegate = self
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = backgroundColor

        imageView.image = avatarImage
        circleView.addSubview(imageView)
        circleView.clipsToBounds = true
        circleView.translatesAutoresizingMaskIntoConstraints = false
        imageView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(circleView)
        NSLayoutConstraint.activate([
            imageView.topAnchor.constraint(equalTo: circleView.topAnchor),
            imageView.leadingAnchor.constraint(equalTo: circleView.leadingAnchor),
            imageView.trailingAnchor.constraint(equalTo: circleView.trailingAnchor),
            imageView.bottomAnchor.constraint(equalTo: circleView.bottomAnchor),

            circleView.widthAnchor.constraint(equalTo: circleView.heightAnchor),

            circleView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            circleView.centerYAnchor.constraint(equalTo: view.centerYAnchor),

            circleView.widthAnchor.constraint(lessThanOrEqualTo: view.widthAnchor, constant: -48),
            circleView.heightAnchor.constraint(lessThanOrEqualTo: view.heightAnchor, constant: -48),
        ])
        let highPriorityConstraints = [
            circleView.widthAnchor.constraint(equalTo: view.widthAnchor, constant: -48),
            circleView.heightAnchor.constraint(equalTo: view.heightAnchor, constant: -48),
        ]
        highPriorityConstraints.forEach { $0.priority = .defaultHigh }
        NSLayoutConstraint.activate(highPriorityConstraints)

        // Use UINavigationBar so that close button X on the left has a standard position in all cases.
        let navigationBar = UINavigationBar()
        let appearance = UINavigationBarAppearance()
        appearance.configureWithTransparentBackground()
        navigationBar.standardAppearance = appearance
        navigationBar.compactAppearance = appearance
        navigationBar.scrollEdgeAppearance = appearance
        navigationBar.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(navigationBar)
        navigationBarTopLayoutConstraint = navigationBar.topAnchor.constraint(equalTo: view.topAnchor)
        NSLayoutConstraint.activate([
            navigationBarTopLayoutConstraint!,
            navigationBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            navigationBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        ])

        let navigationItem = UINavigationItem(title: "")
        navigationItem.leftBarButtonItem = UIBarButtonItem(
            image: Theme.iconImage(.buttonX),
            style: .plain,
            target: self,
            action: #selector(didTapClose),
        )
        navigationBar.setItems([navigationItem], animated: false)

        if #unavailable(iOS 26) {
            overrideUserInterfaceStyle = .dark
            navigationBar.tintColor = Theme.darkThemeNavbarIconColor
        }

        interactiveDismissal.addGestureRecognizer(to: view)
    }

    override func viewSafeAreaInsetsDidChange() {
        super.viewSafeAreaInsetsDidChange()
        if let navigationBarTopLayoutConstraint {
            //
            // Copied from MediaPageViewController.
            //

            // 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
            }
            navigationBarTopLayoutConstraint.constant = topInset
        }
    }

    @objc
    private func didTapClose() {
        performInteractiveDismissal(animated: true)
    }

    func performInteractiveDismissal(animated: Bool) {
        dismiss(animated: animated)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

extension AvatarViewController: MediaPresentationContextProvider {
    func mediaPresentationContext(item: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? {
        view.layoutIfNeeded()

        let backgroundColor: UIColor = if #available(iOS 26, *) { backgroundColor } else { .black }
        return MediaPresentationContext(
            mediaView: circleView,
            presentationFrame: circleView.frame,
            backgroundColor: backgroundColor,
            mediaViewShape: .circle,
        )
    }

    func mediaWillPresent(toContext: MediaPresentationContext) {
        view.backgroundColor = .clear
    }

    func mediaDidPresent(toContext: MediaPresentationContext) {
        view.backgroundColor = backgroundColor
    }

    func mediaWillDismiss(fromContext: MediaPresentationContext) {
        view.backgroundColor = .clear
    }

    func mediaDidDismiss(fromContext: MediaPresentationContext) {
        view.backgroundColor = backgroundColor
    }
}

extension AvatarViewController: UIViewControllerTransitioningDelegate {
    func animationController(
        forPresented presented: UIViewController,
        presenting: UIViewController,
        source: UIViewController,
    ) -> UIViewControllerAnimatedTransitioning? {
        return MediaZoomAnimationController(image: avatarImage)
    }

    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        let animationController = MediaDismissAnimationController(
            image: avatarImage,
            interactionController: interactiveDismissal,
        )
        interactiveDismissal.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
    }
}