mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
318 lines
12 KiB
Swift
318 lines
12 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import UIKit
|
|
import SessionUIKit
|
|
import SessionMessagingKit
|
|
import SessionUtilitiesKit
|
|
|
|
struct MediaPresentationContext {
|
|
let mediaView: SessionImageView
|
|
let presentationFrame: CGRect
|
|
let cornerRadius: CGFloat
|
|
let cornerMask: CACornerMask
|
|
}
|
|
|
|
// There are two kinds of AnimationControllers that interact with the media detail view. Both
|
|
// appear to transition the media view from one VC to it's corresponding location in the
|
|
// destination VC.
|
|
//
|
|
// MediaPresentationContextProvider is either a target or destination VC which can provide the
|
|
// details necessary to facilite this animation.
|
|
//
|
|
// First, the MediaZoomAnimationController is non-interactive. We use it whenever we're going to
|
|
// show the Media detail pager.
|
|
//
|
|
// We can get there several ways:
|
|
// From conversation settings, this can be a push or a pop from the tileView.
|
|
// From conversationView/MessageDetails this can be a modal present or a pop from the tile view.
|
|
//
|
|
// The other animation controller, the MediaDismissAnimationController is used when we're going to
|
|
// stop showing the media pager. This can be a pop to the tile view, or a modal dismiss.
|
|
protocol MediaPresentationContextProvider {
|
|
func mediaPresentationContext(mediaId: String, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext?
|
|
func lowestViewToRenderAboveContent() -> UIView?
|
|
}
|
|
|
|
private struct ClippedHole {
|
|
let frame: CGRect
|
|
let clippedCornerRadius: CGFloat
|
|
let maskedCorners: CACornerMask
|
|
|
|
init(holeFrame: CGRect, cornerRadius: CGFloat, viewport: CGRect?) {
|
|
guard let viewport: CGRect = viewport else {
|
|
self.frame = holeFrame
|
|
self.clippedCornerRadius = cornerRadius
|
|
self.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
|
return
|
|
}
|
|
|
|
self.frame = holeFrame.intersection(viewport)
|
|
var corners: CACornerMask = []
|
|
|
|
// Top-left corner
|
|
if holeFrame.minX >= viewport.minX && holeFrame.minY >= viewport.minY {
|
|
corners.insert(.layerMinXMinYCorner)
|
|
}
|
|
|
|
// Top-right corner
|
|
if holeFrame.maxX <= viewport.maxX && holeFrame.minY >= viewport.minY {
|
|
corners.insert(.layerMaxXMinYCorner)
|
|
}
|
|
|
|
// Bottom-left corner
|
|
if holeFrame.minX >= viewport.minX && holeFrame.maxY <= viewport.maxY {
|
|
corners.insert(.layerMinXMaxYCorner)
|
|
}
|
|
|
|
// Bottom-right corner
|
|
if holeFrame.maxX <= viewport.maxX && holeFrame.maxY <= viewport.maxY {
|
|
corners.insert(.layerMaxXMaxYCorner)
|
|
}
|
|
|
|
self.clippedCornerRadius = (corners.isEmpty ? 0 : cornerRadius)
|
|
self.maskedCorners = corners
|
|
}
|
|
|
|
func createPath() -> UIBezierPath {
|
|
if maskedCorners.isEmpty || clippedCornerRadius == 0 {
|
|
return UIBezierPath(rect: frame)
|
|
}
|
|
|
|
let path: UIBezierPath = UIBezierPath()
|
|
let topLeft: CGPoint = frame.origin
|
|
let topRight: CGPoint = CGPoint(x: frame.maxX, y: frame.minY)
|
|
let bottomRight: CGPoint = CGPoint(x: frame.maxX, y: frame.maxY)
|
|
let bottomLeft: CGPoint = CGPoint(x: frame.minX, y: frame.maxY)
|
|
|
|
// Start from top-left
|
|
if maskedCorners.contains(.layerMinXMinYCorner) {
|
|
path.move(to: CGPoint(x: topLeft.x + clippedCornerRadius, y: topLeft.y))
|
|
path.addArc(
|
|
withCenter: CGPoint(x: topLeft.x + clippedCornerRadius, y: topLeft.y + clippedCornerRadius),
|
|
radius: clippedCornerRadius,
|
|
startAngle: .pi * 1.5,
|
|
endAngle: .pi,
|
|
clockwise: false
|
|
)
|
|
} else {
|
|
path.move(to: topLeft)
|
|
}
|
|
|
|
// Left edge to bottom-left
|
|
if maskedCorners.contains(.layerMinXMaxYCorner) {
|
|
path.addLine(to: CGPoint(x: bottomLeft.x, y: bottomLeft.y - clippedCornerRadius))
|
|
path.addArc(
|
|
withCenter: CGPoint(x: bottomLeft.x + clippedCornerRadius, y: bottomLeft.y - clippedCornerRadius),
|
|
radius: clippedCornerRadius,
|
|
startAngle: .pi,
|
|
endAngle: .pi * 0.5,
|
|
clockwise: false
|
|
)
|
|
} else {
|
|
path.addLine(to: bottomLeft)
|
|
}
|
|
|
|
// Bottom edge to bottom-right
|
|
if maskedCorners.contains(.layerMaxXMaxYCorner) {
|
|
path.addLine(to: CGPoint(x: bottomRight.x - clippedCornerRadius, y: bottomRight.y))
|
|
path.addArc(
|
|
withCenter: CGPoint(x: bottomRight.x - clippedCornerRadius, y: bottomRight.y - clippedCornerRadius),
|
|
radius: clippedCornerRadius,
|
|
startAngle: .pi * 0.5,
|
|
endAngle: 0,
|
|
clockwise: false
|
|
)
|
|
} else {
|
|
path.addLine(to: bottomRight)
|
|
}
|
|
|
|
// Right edge to top-right
|
|
if maskedCorners.contains(.layerMaxXMinYCorner) {
|
|
path.addLine(to: CGPoint(x: topRight.x, y: topRight.y + clippedCornerRadius))
|
|
path.addArc(
|
|
withCenter: CGPoint(x: topRight.x - clippedCornerRadius, y: topRight.y + clippedCornerRadius),
|
|
radius: clippedCornerRadius,
|
|
startAngle: 0,
|
|
endAngle: .pi * 1.5,
|
|
clockwise: false
|
|
)
|
|
} else {
|
|
path.addLine(to: topRight)
|
|
}
|
|
|
|
path.close()
|
|
return path
|
|
}
|
|
}
|
|
|
|
class MediaAnimationController: NSObject {
|
|
let dependencies: Dependencies
|
|
let attachment: Attachment
|
|
|
|
private var displayLink: CADisplayLink?
|
|
private var maskLayer: CAShapeLayer?
|
|
private weak var maskedView: UIView?
|
|
private var maskViewport: CGRect?
|
|
private(set) weak var transitionView: UIView?
|
|
|
|
private var fromBelowContainer: UIView?
|
|
private var toBelowContainer: UIView?
|
|
private var fromAboveViews: [UIView] = []
|
|
private var toAboveViews: [UIView] = []
|
|
|
|
init(attachment: Attachment, using dependencies: Dependencies) {
|
|
self.dependencies = dependencies
|
|
self.attachment = attachment
|
|
|
|
super.init()
|
|
}
|
|
|
|
func extractContextProvider(from viewController: UIViewController) -> MediaPresentationContextProvider? {
|
|
switch viewController {
|
|
case let contextProvider as MediaPresentationContextProvider:
|
|
return contextProvider
|
|
|
|
case let topBannerController as TopBannerController:
|
|
guard
|
|
let firstChild: UIViewController = topBannerController.children.first,
|
|
let navController: UINavigationController = firstChild as? UINavigationController,
|
|
let contextProvider = navController.topViewController as? MediaPresentationContextProvider
|
|
else { return nil }
|
|
|
|
return contextProvider
|
|
|
|
case let navController as UINavigationController:
|
|
guard
|
|
let contextProvider = navController.topViewController as? MediaPresentationContextProvider
|
|
else { return nil }
|
|
|
|
return contextProvider
|
|
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
func extractNavigationController(from viewController: UIViewController) -> UINavigationController? {
|
|
switch viewController {
|
|
case let topBannerController as TopBannerController:
|
|
guard
|
|
let firstChild: UIViewController = topBannerController.children.first,
|
|
let navController: UINavigationController = firstChild as? UINavigationController
|
|
else { return nil }
|
|
|
|
return navController
|
|
|
|
case let navController as UINavigationController: return navController
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
func createAndAddMask(
|
|
to view: UIView,
|
|
holeFrame: CGRect,
|
|
cornerRadius: CGFloat,
|
|
in containerView: UIView,
|
|
viewport: CGRect? = nil
|
|
) {
|
|
let maskLayer: CAShapeLayer = CAShapeLayer()
|
|
let holeFrameInView: CGRect = view.convert(holeFrame, from: containerView)
|
|
let viewportInView: CGRect? = viewport.map { view.convert($0, from: containerView) }
|
|
let clippedHole: ClippedHole = ClippedHole(
|
|
holeFrame: holeFrameInView,
|
|
cornerRadius: cornerRadius,
|
|
viewport: viewportInView
|
|
)
|
|
|
|
let path: UIBezierPath = UIBezierPath(rect: view.bounds)
|
|
let hole: UIBezierPath = clippedHole.createPath()
|
|
path.append(hole)
|
|
path.usesEvenOddFillRule = true
|
|
maskLayer.path = path.cgPath
|
|
maskLayer.fillRule = .evenOdd
|
|
view.layer.mask = maskLayer
|
|
|
|
self.maskLayer = maskLayer
|
|
self.maskedView = view
|
|
self.maskViewport = viewport
|
|
}
|
|
|
|
func insertTransitionView(
|
|
_ transitionView: UIView,
|
|
intoView view: UIView,
|
|
contextProvider: MediaPresentationContextProvider,
|
|
startFrame: CGRect,
|
|
containerView: UIView
|
|
) {
|
|
self.transitionView = transitionView
|
|
|
|
if
|
|
let targetView: UIView = contextProvider.lowestViewToRenderAboveContent(),
|
|
let targetSuperview: UIView = targetView.superview
|
|
{
|
|
let frameInSuperview: CGRect = targetSuperview.convert(startFrame, from: containerView)
|
|
transitionView.frame = frameInSuperview
|
|
targetSuperview.insertSubview(transitionView, belowSubview: targetView)
|
|
}
|
|
else if let navBar: UIView = view.subviews.first(where: { $0 is UINavigationBar }) {
|
|
let frameInTargetView: CGRect = view.convert(startFrame, from: containerView)
|
|
transitionView.frame = frameInTargetView
|
|
view.insertSubview(transitionView, belowSubview: navBar)
|
|
} else {
|
|
transitionView.frame = startFrame
|
|
containerView.insertSubview(transitionView, at: 0)
|
|
}
|
|
}
|
|
|
|
func startDisplayLink() {
|
|
let displayLink: CADisplayLink = CADisplayLink(target: self, selector: #selector(updateMask))
|
|
displayLink.add(to: .main, forMode: .common)
|
|
self.displayLink = displayLink
|
|
}
|
|
|
|
func stopDisplayLink() {
|
|
displayLink?.invalidate()
|
|
displayLink = nil
|
|
}
|
|
|
|
func removeMask() {
|
|
maskedView?.layer.mask = nil
|
|
maskLayer = nil
|
|
maskedView = nil
|
|
}
|
|
|
|
func cleanUp() {
|
|
stopDisplayLink()
|
|
maskLayer = nil
|
|
maskedView = nil
|
|
transitionView = nil
|
|
}
|
|
|
|
@objc private func updateMask() {
|
|
guard
|
|
let maskLayer: CAShapeLayer = self.maskLayer,
|
|
let maskedView: UIView = self.maskedView,
|
|
let transitionView: UIView = self.transitionView,
|
|
let containerView: UIWindow = transitionView.window
|
|
else { return }
|
|
|
|
let currentFrameInSuperview: CGRect = (transitionView.layer.presentation()?.frame ?? transitionView.frame)
|
|
let currentCornerRadius: CGFloat = (transitionView.layer.presentation()?.cornerRadius ?? transitionView.layer.cornerRadius)
|
|
let currentFrameInContainer: CGRect = (transitionView.superview?.convert(currentFrameInSuperview, to: containerView) ?? currentFrameInSuperview)
|
|
let holeFrameInView: CGRect = maskedView.convert(currentFrameInContainer, from: containerView)
|
|
let viewportInView: CGRect? = self.maskViewport.map { maskedView.convert($0, from: containerView) }
|
|
let clippedHole: ClippedHole = ClippedHole(
|
|
holeFrame: holeFrameInView,
|
|
cornerRadius: currentCornerRadius,
|
|
viewport: viewportInView
|
|
)
|
|
|
|
// Update the mask path
|
|
let path: UIBezierPath = UIBezierPath(rect: maskedView.bounds)
|
|
let hole: UIBezierPath = clippedHole.createPath()
|
|
path.append(hole)
|
|
path.usesEvenOddFillRule = true
|
|
|
|
maskLayer.path = path.cgPath
|
|
}
|
|
}
|