session-ios/Session/Conversations/Input View/VoiceMessageRecordingView.s...

475 lines
17 KiB
Swift
Raw Normal View History

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
2021-02-16 03:57:30 +01:00
import UIKit
import SessionUIKit
import SignalUtilitiesKit
final class VoiceMessageRecordingView: UIView {
2021-02-16 03:57:30 +01:00
private let voiceMessageButtonFrame: CGRect
2021-04-01 05:24:10 +02:00
private weak var delegate: VoiceMessageRecordingViewDelegate?
2021-02-16 03:57:30 +01:00
private lazy var slideToCancelStackViewRightConstraint = slideToCancelStackView.pin(.right, to: .right, of: self)
private lazy var slideToCancelLabelCenterHorizontalConstraint = slideToCancelLabel.center(.horizontal, in: self)
private lazy var pulseViewWidthConstraint = pulseView.set(.width, to: VoiceMessageRecordingView.circleSize)
private lazy var pulseViewHeightConstraint = pulseView.set(.height, to: VoiceMessageRecordingView.circleSize)
2021-02-16 04:22:39 +01:00
private lazy var lockViewBottomConstraint = lockView.pin(.bottom, to: .top, of: self, withInset: Values.mediumSpacing)
2021-02-16 03:57:30 +01:00
private let recordingStartDate = Date()
private var recordingTimer: Timer?
// MARK: - UI Components
2021-02-16 09:28:32 +01:00
private lazy var iconImageView: UIImageView = {
let result: UIImageView = UIImageView()
result.image = UIImage(named: "Microphone")?
.withRenderingMode(.alwaysTemplate)
result.themeTintColor = .white
2021-02-16 09:28:32 +01:00
result.contentMode = .scaleAspectFit
result.set(.width, to: VoiceMessageRecordingView.iconSize)
result.set(.height, to: VoiceMessageRecordingView.iconSize)
2021-02-16 09:28:32 +01:00
return result
}()
private lazy var circleView: UIView = {
let result: UIView = UIView()
result.clipsToBounds = true
result.themeBackgroundColor = .danger
result.set(.width, to: VoiceMessageRecordingView.circleSize)
result.set(.height, to: VoiceMessageRecordingView.circleSize)
result.layer.cornerRadius = (VoiceMessageRecordingView.circleSize / 2)
2021-02-16 09:28:32 +01:00
return result
}()
2021-02-16 03:57:30 +01:00
private lazy var pulseView: UIView = {
let result: UIView = UIView()
result.themeBackgroundColor = .danger
result.layer.cornerRadius = (VoiceMessageRecordingView.circleSize / 2)
2021-02-16 03:57:30 +01:00
result.layer.masksToBounds = true
result.alpha = 0.5
2021-02-16 03:57:30 +01:00
return result
}()
private lazy var slideToCancelStackView: UIStackView = {
let result: UIStackView = UIStackView()
2021-02-16 03:57:30 +01:00
result.axis = .horizontal
result.spacing = Values.smallSpacing
result.alignment = .center
2021-02-16 03:57:30 +01:00
return result
}()
2021-02-16 09:28:32 +01:00
private lazy var chevronImageView: UIImageView = {
let result: UIImageView = UIImageView(
image: UIImage(named: "small_chevron_left")?
.withRenderingMode(.alwaysTemplate)
)
result.themeTintColor = .textPrimary
2021-02-16 09:28:32 +01:00
result.contentMode = .scaleAspectFit
result.alpha = Values.mediumOpacity
result.set(.width, to: VoiceMessageRecordingView.chevronSize)
result.set(.height, to: VoiceMessageRecordingView.chevronSize)
2021-02-16 09:28:32 +01:00
return result
}()
2021-02-16 03:57:30 +01:00
private lazy var slideToCancelLabel: UILabel = {
let result: UILabel = UILabel()
2021-02-16 03:57:30 +01:00
result.font = .systemFont(ofSize: Values.smallFontSize)
result.text = "vc_conversation_voice_message_cancel_message".localized()
result.themeTextColor = .textPrimary
result.alpha = Values.mediumOpacity
2021-02-16 03:57:30 +01:00
return result
}()
2021-02-16 09:28:32 +01:00
private lazy var cancelButton: UIButton = {
let result: UIButton = UIButton()
result.setTitle("cancel".localized(), for: .normal)
result.titleLabel?.font = .boldSystemFont(ofSize: Values.smallFontSize)
result.setThemeTitleColor(.textPrimary, for: .normal)
2021-02-16 09:28:32 +01:00
result.addTarget(self, action: #selector(handleCancelButtonTapped), for: UIControl.Event.touchUpInside)
result.alpha = 0
2021-02-16 09:28:32 +01:00
return result
}()
2021-02-16 03:57:30 +01:00
private lazy var durationStackView: UIStackView = {
let result: UIStackView = UIStackView()
2021-02-16 03:57:30 +01:00
result.axis = .horizontal
result.spacing = Values.smallSpacing
result.alignment = .center
2021-02-16 03:57:30 +01:00
return result
}()
private lazy var dotView: UIView = {
let result: UIView = UIView()
result.clipsToBounds = true
result.themeBackgroundColor = .danger
result.set(.width, to: VoiceMessageRecordingView.dotSize)
result.set(.height, to: VoiceMessageRecordingView.dotSize)
result.layer.cornerRadius = (VoiceMessageRecordingView.dotSize / 2)
2021-02-16 03:57:30 +01:00
return result
}()
private lazy var durationLabel: UILabel = {
let result: UILabel = UILabel()
2021-02-16 03:57:30 +01:00
result.font = .systemFont(ofSize: Values.smallFontSize)
result.themeTextColor = .textPrimary
result.text = "0:00"
2021-02-16 03:57:30 +01:00
return result
}()
2021-02-16 04:22:39 +01:00
private lazy var lockView = LockView()
// MARK: - Settings
2021-02-16 03:57:30 +01:00
private static let circleSize: CGFloat = 96
private static let pulseSize: CGFloat = 24
2021-02-16 09:28:32 +01:00
private static let iconSize: CGFloat = 28
2021-02-16 03:57:30 +01:00
private static let chevronSize: CGFloat = 16
private static let dotSize: CGFloat = 16
2021-02-16 09:28:32 +01:00
private static let lockViewHitMargin: CGFloat = 40
2021-02-16 03:57:30 +01:00
// MARK: - Lifecycle
2021-04-01 05:24:10 +02:00
init(voiceMessageButtonFrame: CGRect, delegate: VoiceMessageRecordingViewDelegate?) {
2021-02-16 03:57:30 +01:00
self.voiceMessageButtonFrame = voiceMessageButtonFrame
self.delegate = delegate
2021-02-16 03:57:30 +01:00
super.init(frame: CGRect.zero)
2021-02-16 03:57:30 +01:00
setUpViewHierarchy()
2021-02-16 09:28:32 +01:00
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
2021-02-16 03:57:30 +01:00
self?.updateDurationLabel()
}
}
override init(frame: CGRect) {
preconditionFailure("Use init(voiceMessageButtonFrame:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(voiceMessageButtonFrame:) instead.")
}
deinit {
recordingTimer?.invalidate()
}
private func setUpViewHierarchy() {
// Icon
2021-02-16 09:28:32 +01:00
let iconSize = VoiceMessageRecordingView.iconSize
2021-02-16 03:57:30 +01:00
addSubview(iconImageView)
2021-02-16 03:57:30 +01:00
let voiceMessageButtonCenter = voiceMessageButtonFrame.center
iconImageView.pin(.left, to: .left, of: self, withInset: (voiceMessageButtonCenter.x - (iconSize / 2)))
iconImageView.pin(.top, to: .top, of: self, withInset: (voiceMessageButtonCenter.y - (iconSize / 2)))
2021-02-16 03:57:30 +01:00
// Circle
insertSubview(circleView, at: 0)
circleView.center(in: iconImageView)
2021-02-16 03:57:30 +01:00
// Pulse
insertSubview(pulseView, at: 0)
pulseView.center(in: circleView)
2021-02-16 03:57:30 +01:00
// Slide to cancel stack view
slideToCancelStackView.addArrangedSubview(chevronImageView)
slideToCancelStackView.addArrangedSubview(slideToCancelLabel)
addSubview(slideToCancelStackView)
slideToCancelStackViewRightConstraint.isActive = true
slideToCancelStackView.center(.vertical, in: iconImageView)
2021-02-16 09:28:32 +01:00
// Cancel button
addSubview(cancelButton)
cancelButton.center(.horizontal, in: self)
cancelButton.center(.vertical, in: iconImageView)
2021-02-16 03:57:30 +01:00
// Duration stack view
durationStackView.addArrangedSubview(dotView)
durationStackView.addArrangedSubview(durationLabel)
addSubview(durationStackView)
2021-02-16 03:57:30 +01:00
durationStackView.pin(.left, to: .left, of: self, withInset: Values.largeSpacing)
durationStackView.center(.vertical, in: iconImageView)
2021-02-16 03:57:30 +01:00
// Lock view
addSubview(lockView)
lockView.centerXAnchor.constraint(equalTo: iconImageView.centerXAnchor, constant: 2).isActive = true
2021-02-16 04:22:39 +01:00
lockViewBottomConstraint.isActive = true
2021-02-16 03:57:30 +01:00
}
// MARK: - Updating
2021-02-16 03:57:30 +01:00
@objc private func updateDurationLabel() {
let interval = Date().timeIntervalSince(recordingStartDate)
durationLabel.text = OWSFormat.formatDurationSeconds(Int(interval))
}
// MARK: - Animation
2021-02-16 03:57:30 +01:00
func animate() {
layoutIfNeeded()
2021-02-16 04:22:39 +01:00
slideToCancelStackViewRightConstraint.isActive = false
slideToCancelLabelCenterHorizontalConstraint.isActive = true
lockViewBottomConstraint.constant = -Values.mediumSpacing
2021-02-16 04:22:39 +01:00
UIView.animate(withDuration: 0.25, animations: { [weak self] in
self?.alpha = 1
self?.layoutIfNeeded()
2021-02-16 04:22:39 +01:00
}, completion: { [weak self] _ in
self?.fadeOutDotView()
self?.pulse()
2021-02-16 03:57:30 +01:00
})
}
private func fadeOutDotView() {
2021-02-16 04:22:39 +01:00
UIView.animate(withDuration: 0.5, animations: { [weak self] in
self?.dotView.alpha = 0
}, completion: { [weak self] _ in
self?.fadeInDotView()
2021-02-16 03:57:30 +01:00
})
}
private func fadeInDotView() {
2021-02-16 04:22:39 +01:00
UIView.animate(withDuration: 0.5, animations: { [weak self] in
self?.dotView.alpha = 1
}, completion: { [weak self] _ in
self?.fadeOutDotView()
2021-02-16 03:57:30 +01:00
})
}
private func pulse() {
let collapsedSize = VoiceMessageRecordingView.circleSize
let collapsedFrame = CGRect(center: pulseView.center, size: CGSize(width: collapsedSize, height: collapsedSize))
let expandedSize = VoiceMessageRecordingView.circleSize + VoiceMessageRecordingView.pulseSize
let expandedFrame = CGRect(center: pulseView.center, size: CGSize(width: expandedSize, height: expandedSize))
pulseViewWidthConstraint.constant = expandedSize
pulseViewHeightConstraint.constant = expandedSize
2021-02-16 04:22:39 +01:00
UIView.animate(withDuration: 1, animations: { [weak self] in
self?.layoutIfNeeded()
self?.pulseView.frame = expandedFrame
self?.pulseView.layer.cornerRadius = (expandedSize / 2)
self?.pulseView.alpha = 0
2021-02-16 04:22:39 +01:00
}, completion: { [weak self] _ in
self?.pulseViewWidthConstraint.constant = collapsedSize
self?.pulseViewHeightConstraint.constant = collapsedSize
self?.pulseView.frame = collapsedFrame
self?.pulseView.layer.cornerRadius = (collapsedSize / 2)
self?.pulseView.alpha = 0.5
self?.pulse()
2021-02-16 03:57:30 +01:00
})
}
// MARK: - Interaction
2021-02-16 09:28:32 +01:00
func handleLongPressMoved(to location: CGPoint) {
if location.x < bounds.center.x {
let translationX = location.x - bounds.center.x
let sign: CGFloat = -1
let chevronDamping: CGFloat = 4
let labelDamping: CGFloat = 3
let chevronX = (chevronDamping * (sqrt(abs(translationX)) / sqrt(chevronDamping))) * sign
let labelX = (labelDamping * (sqrt(abs(translationX)) / sqrt(labelDamping))) * sign
2021-02-16 09:28:32 +01:00
chevronImageView.transform = CGAffineTransform(translationX: chevronX, y: 0)
slideToCancelLabel.transform = CGAffineTransform(translationX: labelX, y: 0)
}
else {
2021-02-16 09:28:32 +01:00
chevronImageView.transform = .identity
slideToCancelLabel.transform = .identity
}
2021-02-16 22:01:54 +01:00
if isValidLockViewLocation(location) {
if !lockView.isExpanded {
UIView.animate(withDuration: 0.25) {
self.lockViewBottomConstraint.constant = -Values.mediumSpacing + LockView.expansionMargin
}
}
lockView.expandIfNeeded()
}
else {
2021-02-16 22:01:54 +01:00
if lockView.isExpanded {
UIView.animate(withDuration: 0.25) {
self.lockViewBottomConstraint.constant = -Values.mediumSpacing
}
}
lockView.collapseIfNeeded()
}
2021-02-16 09:28:32 +01:00
}
func handleLongPressEnded(at location: CGPoint) {
if pulseView.frame.contains(location) {
2021-04-01 05:24:10 +02:00
delegate?.endVoiceMessageRecording()
}
else if isValidLockViewLocation(location) {
2021-02-16 09:28:32 +01:00
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleCircleViewTap))
circleView.addGestureRecognizer(tapGestureRecognizer)
2021-02-16 09:28:32 +01:00
UIView.animate(withDuration: 0.25, delay: 0, options: .transitionCrossDissolve, animations: {
self.lockView.alpha = 0
self.iconImageView.image = UIImage(named: "ArrowUp")?.withRenderingMode(.alwaysTemplate)
2021-02-16 09:28:32 +01:00
self.slideToCancelStackView.alpha = 0
self.cancelButton.alpha = 1
}, completion: { _ in
// Do nothing
})
}
else {
2021-04-01 05:24:10 +02:00
delegate?.cancelVoiceMessageRecording()
}
}
2021-02-16 09:28:32 +01:00
@objc private func handleCircleViewTap() {
2021-04-01 05:24:10 +02:00
delegate?.endVoiceMessageRecording()
2021-02-16 09:28:32 +01:00
}
@objc private func handleCancelButtonTapped() {
2021-04-01 05:24:10 +02:00
delegate?.cancelVoiceMessageRecording()
2021-02-16 09:28:32 +01:00
}
2021-02-16 22:01:54 +01:00
// MARK: - Convenience
2021-02-16 22:01:54 +01:00
private func isValidLockViewLocation(_ location: CGPoint) -> Bool {
let lockViewHitMargin = VoiceMessageRecordingView.lockViewHitMargin
2021-02-16 22:01:54 +01:00
return location.y < 0 && location.x > (lockView.frame.minX - lockViewHitMargin) && location.x < (lockView.frame.maxX + lockViewHitMargin)
}
2021-02-16 03:57:30 +01:00
}
2021-02-16 04:22:39 +01:00
// MARK: - Lock View
2021-02-16 04:22:39 +01:00
extension VoiceMessageRecordingView {
fileprivate final class LockView: UIView {
2021-02-16 22:01:54 +01:00
private lazy var widthConstraint = set(.width, to: LockView.width)
private(set) var isExpanded = false
private lazy var stackView: UIStackView = {
let result: UIStackView = UIStackView()
2021-02-16 22:01:54 +01:00
result.axis = .vertical
result.spacing = Values.smallSpacing
result.alignment = .center
result.isLayoutMarginsRelativeArrangement = true
result.layoutMargins = UIEdgeInsets(top: 12, leading: 0, bottom: 8, trailing: 0)
2021-02-16 22:01:54 +01:00
return result
}()
2021-02-16 04:22:39 +01:00
private static let width: CGFloat = 44
2021-02-16 22:01:54 +01:00
static let expansionMargin: CGFloat = 3
2021-02-16 04:22:39 +01:00
private static let lockIconSize: CGFloat = 20
private static let chevronIconSize: CGFloat = 20
override init(frame: CGRect) {
super.init(frame: frame)
2021-02-16 04:22:39 +01:00
setUpViewHierarchy()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
2021-02-16 04:22:39 +01:00
setUpViewHierarchy()
}
private func setUpViewHierarchy() {
// Background & blur
let backgroundView: UIView = UIView()
backgroundView.themeBackgroundColor = .backgroundSecondary
2021-02-16 04:22:39 +01:00
backgroundView.alpha = Values.lowOpacity
addSubview(backgroundView)
backgroundView.pin(to: self)
let blurView = UIVisualEffectView()
2021-02-16 04:22:39 +01:00
addSubview(blurView)
blurView.pin(to: self)
ThemeManager.onThemeChange(observer: blurView) { [weak blurView] theme, _ in
switch theme.interfaceStyle {
case .light: blurView?.effect = UIBlurEffect(style: .light)
default: blurView?.effect = UIBlurEffect(style: .dark)
}
}
2021-02-16 04:22:39 +01:00
// Size & shape
2021-02-16 22:01:54 +01:00
widthConstraint.isActive = true
layer.cornerRadius = (LockView.width / 2)
2021-02-16 04:22:39 +01:00
layer.masksToBounds = true
2021-02-16 04:22:39 +01:00
// Border
themeBorderColor = .borderSeparator
2021-02-16 04:22:39 +01:00
layer.borderWidth = 1
2021-02-16 04:22:39 +01:00
// Lock icon
let lockIconImageView: UIImageView = UIImageView(
image: UIImage(named: "ic_lock_outline")?
.withRenderingMode(.alwaysTemplate)
)
lockIconImageView.themeTintColor = .textPrimary
lockIconImageView.set(.width, to: LockView.lockIconSize)
lockIconImageView.set(.height, to: LockView.lockIconSize)
2021-02-16 22:01:54 +01:00
stackView.addArrangedSubview(lockIconImageView)
2021-02-16 04:22:39 +01:00
// Chevron icon
let chevronIconImageView: UIImageView = UIImageView(
image: UIImage(named: "ic_chevron_up")?
.withRenderingMode(.alwaysTemplate)
)
chevronIconImageView.themeTintColor = .textPrimary
chevronIconImageView.set(.width, to: LockView.chevronIconSize)
chevronIconImageView.set(.height, to: LockView.chevronIconSize)
2021-02-16 22:01:54 +01:00
stackView.addArrangedSubview(chevronIconImageView)
2021-02-16 04:22:39 +01:00
// Stack view
addSubview(stackView)
stackView.pin(to: self)
}
2021-02-16 22:01:54 +01:00
func expandIfNeeded() {
guard !isExpanded else { return }
2021-02-16 22:01:54 +01:00
isExpanded = true
2021-02-16 22:01:54 +01:00
let expansionMargin = LockView.expansionMargin
let newWidth = LockView.width + 2 * expansionMargin
2021-02-16 22:01:54 +01:00
widthConstraint.constant = newWidth
2021-02-16 22:01:54 +01:00
UIView.animate(withDuration: 0.25) {
self.layer.cornerRadius = newWidth / 2
self.stackView.layoutMargins = UIEdgeInsets(top: 12 + expansionMargin, leading: 0, bottom: 8 + expansionMargin, trailing: 0)
self.layoutIfNeeded()
}
}
func collapseIfNeeded() {
guard isExpanded else { return }
2021-02-16 22:01:54 +01:00
isExpanded = false
2021-02-16 22:01:54 +01:00
let newWidth = LockView.width
widthConstraint.constant = newWidth
2021-02-16 22:01:54 +01:00
UIView.animate(withDuration: 0.25) {
self.layer.cornerRadius = newWidth / 2
self.stackView.layoutMargins = UIEdgeInsets(top: 12, leading: 0, bottom: 8, trailing: 0)
self.layoutIfNeeded()
}
}
2021-02-16 04:22:39 +01:00
}
}
// MARK: - Delegate
protocol VoiceMessageRecordingViewDelegate: AnyObject {
func startVoiceMessageRecording()
func endVoiceMessageRecording()
func cancelVoiceMessageRecording()
}