mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
313 lines
11 KiB
Swift
313 lines
11 KiB
Swift
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
|
|
import UIKit
|
|
import MediaPlayer
|
|
import SessionUIKit
|
|
import SignalUtilitiesKit
|
|
import SessionUtilitiesKit
|
|
|
|
// This kind of view is tricky. I've tried to organize things in the
|
|
// simplest possible way.
|
|
//
|
|
// I've tried to avoid the following sources of confusion:
|
|
//
|
|
// * Points vs. pixels. All variables should have names that
|
|
// reflect the units. Pretty much everything is done in points
|
|
// except rendering of the output image which is done in pixels.
|
|
// * Coordinate systems. You have a) the src image coordinates
|
|
// b) the image view coordinates c) the output image coordinates.
|
|
// Wherever possible, I've tried to use src image coordinates.
|
|
// * Translation & scaling vs. crop region. The crop region is
|
|
// implicit. We represent the crop state using the translation
|
|
// and scaling of the "default" crop region (the largest possible
|
|
// crop region, at the origin (upper left) of the source image.
|
|
// Given the translation & scaling, we can determine a) the crop
|
|
// region b) the rectangle at which the src image should be rendered
|
|
// given a dst view or output context that will yield the
|
|
// appropriate cropping.
|
|
class CropScaleImageViewController: OWSViewController, UIScrollViewDelegate {
|
|
|
|
// MARK: Properties
|
|
|
|
private let dataManager: ImageDataManagerType
|
|
let source: ImageDataManager.DataSource
|
|
|
|
let successCompletion: ((ImageDataManager.DataSource, CGRect) -> Void)
|
|
|
|
// In width/height.
|
|
let dstSizePixels: CGSize
|
|
var dstAspectRatio: CGFloat {
|
|
return dstSizePixels.width / dstSizePixels.height
|
|
}
|
|
|
|
// The size of the src image in points.
|
|
var srcImageSizePoints: CGSize = CGSize.zero
|
|
|
|
// space between the cropping circle and the outside edge of the view
|
|
let maskMargin = CGFloat(20)
|
|
|
|
// MARK: - UI
|
|
|
|
private lazy var scrollView: UIScrollView = {
|
|
let result: UIScrollView = UIScrollView()
|
|
result.delegate = self
|
|
result.minimumZoomScale = 1
|
|
result.maximumZoomScale = 5
|
|
result.showsHorizontalScrollIndicator = false
|
|
result.showsVerticalScrollIndicator = false
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var imageContainerView: UIView = {
|
|
let result: UIView = UIView()
|
|
result.themeBackgroundColor = .clear
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var imageView: SessionImageView = {
|
|
let result: SessionImageView = SessionImageView(dataManager: dataManager)
|
|
result.loadImage(source) { [weak self] buffer in
|
|
guard let self, srcImageSizePoints == .zero else { return }
|
|
|
|
self.srcImageSizePoints = (buffer?.firstFrame.size ?? .zero)
|
|
self.imageView.set(.width, to: self.srcImageSizePoints.width)
|
|
self.imageView.set(.height, to: self.srcImageSizePoints.height)
|
|
self.configureScrollView()
|
|
}
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var buttonStackView: UIStackView = {
|
|
let result: UIStackView = UIStackView(arrangedSubviews: [cancelButton, doneButton])
|
|
result.axis = .horizontal
|
|
result.distribution = .fillEqually
|
|
result.alignment = .fill
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var cancelButton: UIButton = {
|
|
let result: UIButton = UIButton()
|
|
result.titleLabel?.font = .systemFont(ofSize: 18)
|
|
result.setTitle("cancel".localized(), for: .normal)
|
|
result.setThemeTitleColor(.textPrimary, for: .normal)
|
|
result.setThemeBackgroundColor(.backgroundSecondary, for: .highlighted)
|
|
result.contentEdgeInsets = UIEdgeInsets(
|
|
top: Values.mediumSpacing,
|
|
leading: 0,
|
|
bottom: (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? Values.mediumSpacing),
|
|
trailing: 0
|
|
)
|
|
result.addTarget(self, action: #selector(cancelPressed), for: .touchUpInside)
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var doneButton: UIButton = {
|
|
let result: UIButton = UIButton()
|
|
result.titleLabel?.font = .systemFont(ofSize: 18)
|
|
result.setTitle("done".localized(), for: .normal)
|
|
result.setThemeTitleColor(.textPrimary, for: .normal)
|
|
result.setThemeBackgroundColor(.backgroundPrimary, for: .highlighted)
|
|
result.setThemeBackgroundColor(.backgroundSecondary, for: .highlighted)
|
|
result.contentEdgeInsets = UIEdgeInsets(
|
|
top: Values.mediumSpacing,
|
|
leading: 0,
|
|
bottom: (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? Values.mediumSpacing),
|
|
trailing: 0
|
|
)
|
|
result.addTarget(self, action: #selector(donePressed), for: .touchUpInside)
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var maskingView: BezierPathView = {
|
|
let result: BezierPathView = BezierPathView()
|
|
result.configureShapeLayer = { [weak self] layer, bounds in
|
|
guard let self = self else { return }
|
|
|
|
let path = UIBezierPath(rect: bounds)
|
|
let circleRect = cropFrame(forBounds: bounds)
|
|
let radius = circleRect.size.width * 0.5
|
|
let circlePath = UIBezierPath(roundedRect: circleRect, cornerRadius: radius)
|
|
|
|
path.append(circlePath)
|
|
path.usesEvenOddFillRule = true
|
|
|
|
layer.path = path.cgPath
|
|
layer.fillRule = .evenOdd
|
|
layer.themeFillColor = .black
|
|
layer.opacity = 0.75
|
|
}
|
|
|
|
return result
|
|
}()
|
|
|
|
// MARK: Initializers
|
|
|
|
@available(*, unavailable, message:"use other constructor instead.")
|
|
required init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
init(
|
|
source: ImageDataManager.DataSource,
|
|
dstSizePixels: CGSize,
|
|
dataManager: ImageDataManagerType,
|
|
successCompletion: @escaping (ImageDataManager.DataSource, CGRect) -> Void
|
|
) {
|
|
self.dataManager = dataManager
|
|
self.source = source
|
|
self.dstSizePixels = dstSizePixels
|
|
self.successCompletion = successCompletion
|
|
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
// MARK: View Lifecycle
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
createViews()
|
|
}
|
|
|
|
override func viewDidLayoutSubviews() {
|
|
super.viewDidLayoutSubviews()
|
|
|
|
if scrollView.minimumZoomScale == 1.0 && scrollView.bounds.width > 0 {
|
|
configureScrollView()
|
|
}
|
|
}
|
|
|
|
// MARK: - Create Views
|
|
|
|
private func createViews() {
|
|
title = "attachmentsMoveAndScale".localized()
|
|
view.themeBackgroundColor = .backgroundPrimary
|
|
|
|
view.addSubview(scrollView)
|
|
view.addSubview(maskingView)
|
|
view.addSubview(buttonStackView)
|
|
scrollView.addSubview(imageContainerView)
|
|
imageContainerView.addSubview(imageView)
|
|
|
|
scrollView.pin(.top, to: .top, of: view)
|
|
scrollView.pin(.leading, to: .leading, of: view)
|
|
scrollView.pin(.trailing, to: .trailing, of: view)
|
|
scrollView.pin(.bottom, to: .top, of: buttonStackView)
|
|
|
|
maskingView.pin(to: scrollView)
|
|
|
|
imageContainerView.pin(to: scrollView)
|
|
imageView.pin(to: imageContainerView)
|
|
|
|
buttonStackView.pin(.leading, to: .leading, of: view)
|
|
buttonStackView.pin(.trailing, to: .trailing, of: view)
|
|
buttonStackView.pin(.bottom, to: .bottom, of: view)
|
|
}
|
|
|
|
private func configureScrollView() {
|
|
guard srcImageSizePoints.width > 0 && srcImageSizePoints.height > 0 else { return }
|
|
guard scrollView.bounds.width > 0 && scrollView.bounds.height > 0 else { return }
|
|
|
|
// Get the crop circle size
|
|
let cropCircleSize: CGFloat = min(scrollView.bounds.width, scrollView.bounds.height) - (maskMargin * 2)
|
|
|
|
// Calculate the scale to fit the image to fill the crop circle then start at min scale
|
|
let widthScale: CGFloat = (cropCircleSize / srcImageSizePoints.width)
|
|
let heightScale: CGFloat = (cropCircleSize / srcImageSizePoints.height)
|
|
let minScale = max(widthScale, heightScale) // Fill, not fit
|
|
scrollView.minimumZoomScale = minScale
|
|
scrollView.maximumZoomScale = (minScale * 5.0)
|
|
scrollView.zoomScale = minScale
|
|
|
|
// Center the content
|
|
let cropRect: CGRect = cropFrame(forBounds: scrollView.bounds)
|
|
let scaledImageWidth: CGFloat = (srcImageSizePoints.width * minScale)
|
|
let scaledImageHeight: CGFloat = (srcImageSizePoints.height * minScale)
|
|
let offsetX: CGFloat = ((cropCircleSize - scaledImageWidth) / 2)
|
|
let offsetY: CGFloat = ((cropCircleSize - scaledImageHeight) / 2)
|
|
|
|
scrollView.contentInset = UIEdgeInsets(
|
|
top: cropRect.minY,
|
|
left: cropRect.minX,
|
|
bottom: (scrollView.bounds.height - cropRect.maxY),
|
|
right: (scrollView.bounds.width - cropRect.maxX)
|
|
)
|
|
scrollView.contentOffset = CGPoint(
|
|
x: -cropRect.minX - offsetX,
|
|
y: -cropRect.minY - offsetY
|
|
)
|
|
}
|
|
|
|
// Given the current bounds for the image view, return the frame of the
|
|
// crop region within that view.
|
|
private func cropFrame(forBounds bounds: CGRect) -> CGRect {
|
|
let radius: CGFloat = ((min(bounds.size.width, bounds.size.height) * 0.5) - self.maskMargin)
|
|
|
|
return CGRect(
|
|
x: ((bounds.size.width * 0.5) - radius),
|
|
y: ((bounds.size.height * 0.5) - radius),
|
|
width: (radius * 2),
|
|
height: (radius * 2)
|
|
)
|
|
}
|
|
|
|
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
|
return imageContainerView
|
|
}
|
|
|
|
// MARK: - Event Handlers
|
|
|
|
@objc func cancelPressed() {
|
|
dismiss(animated: true, completion: nil)
|
|
}
|
|
|
|
@objc func donePressed() {
|
|
dismiss(animated: true, completion: { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
self.successCompletion(self.source, self.calculateCropRect())
|
|
})
|
|
}
|
|
|
|
// MARK: - Internal Functions
|
|
|
|
private func calculateCropRect() -> CGRect {
|
|
let cropCircleFrame = cropFrame(forBounds: scrollView.bounds)
|
|
let zoomScale = scrollView.zoomScale
|
|
let contentOffset = scrollView.contentOffset
|
|
let contentInset = scrollView.contentInset
|
|
|
|
// Convert to content coordinates
|
|
let contentX = (contentOffset.x + contentInset.left) / zoomScale
|
|
let contentY = (contentOffset.y + contentInset.top) / zoomScale
|
|
|
|
// Crop size in image coordinates
|
|
let cropSize = cropCircleFrame.width / zoomScale
|
|
|
|
// Convert to normalized coordinates (0-1)
|
|
let normalizedX = contentX / srcImageSizePoints.width
|
|
let normalizedY = contentY / srcImageSizePoints.height
|
|
let normalizedWidth = cropSize / srcImageSizePoints.width
|
|
let normalizedHeight = cropSize / srcImageSizePoints.height
|
|
|
|
// Clamp to valid range [0, 1] and ensure width/height don't exceed bounds
|
|
let clampedX = max(0, min(1 - normalizedWidth, normalizedX))
|
|
let clampedY = max(0, min(1 - normalizedHeight, normalizedY))
|
|
let clampedWidth = min(1.0, normalizedWidth, 1.0 - clampedX)
|
|
let clampedHeight = min(1.0, normalizedHeight, 1.0 - clampedY)
|
|
|
|
return CGRect(
|
|
x: clampedX,
|
|
y: clampedY,
|
|
width: clampedWidth,
|
|
height: clampedHeight
|
|
)
|
|
}
|
|
}
|