mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			481 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			481 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import UIKit
 | |
| import SessionUIKit
 | |
| import SessionUtilitiesKit
 | |
| import SignalUtilitiesKit
 | |
| 
 | |
| final class VoiceMessageRecordingView: UIView {
 | |
|     private let voiceMessageButtonFrame: CGRect
 | |
|     private weak var delegate: VoiceMessageRecordingViewDelegate?
 | |
|     private lazy var slideToCancelStackViewTrailingConstraint = slideToCancelStackView.pin(.trailing, to: .trailing, 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)
 | |
|     private lazy var lockViewBottomConstraint = lockView.pin(.bottom, to: .top, of: self, withInset: Values.mediumSpacing)
 | |
|     private let recordingStartDate = Date()
 | |
|     private var recordingTimer: Timer?
 | |
| 
 | |
|     // MARK: - UI Components
 | |
|     
 | |
|     private lazy var iconImageView: UIImageView = {
 | |
|         let result: UIImageView = UIImageView()
 | |
|         result.image = UIImage(named: "Microphone")?
 | |
|             .withRenderingMode(.alwaysTemplate)
 | |
|         result.themeTintColor = .white
 | |
|         result.contentMode = .scaleAspectFit
 | |
|         result.set(.width, to: VoiceMessageRecordingView.iconSize)
 | |
|         result.set(.height, to: VoiceMessageRecordingView.iconSize)
 | |
|         
 | |
|         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)
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var pulseView: UIView = {
 | |
|         let result: UIView = UIView()
 | |
|         result.themeBackgroundColor = .danger
 | |
|         result.layer.cornerRadius = (VoiceMessageRecordingView.circleSize / 2)
 | |
|         result.layer.masksToBounds = true
 | |
|         result.alpha = 0.5
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var slideToCancelStackView: UIStackView = {
 | |
|         let result: UIStackView = UIStackView()
 | |
|         result.axis = .horizontal
 | |
|         result.spacing = Values.smallSpacing
 | |
|         result.alignment = .center
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var chevronImageView: UIImageView = {
 | |
|         let result: UIImageView = UIImageView(
 | |
|             image: (CurrentAppContext().isRTL ?
 | |
|                     UIImage(named: "small_chevron_left")?.withHorizontallyFlippedOrientation() :
 | |
|                     UIImage(named: "small_chevron_left")
 | |
|                 )?
 | |
|                 .withRenderingMode(.alwaysTemplate)
 | |
|         )
 | |
|         result.themeTintColor = .textPrimary
 | |
|         result.contentMode = .scaleAspectFit
 | |
|         result.alpha = Values.mediumOpacity
 | |
|         result.set(.width, to: VoiceMessageRecordingView.chevronSize)
 | |
|         result.set(.height, to: VoiceMessageRecordingView.chevronSize)
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var slideToCancelLabel: UILabel = {
 | |
|         let result: UILabel = UILabel()
 | |
|         result.font = .systemFont(ofSize: Values.smallFontSize)
 | |
|         result.text = "vc_conversation_voice_message_cancel_message".localized()
 | |
|         result.themeTextColor = .textPrimary
 | |
|         result.alpha = Values.mediumOpacity
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     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)
 | |
|         result.addTarget(self, action: #selector(handleCancelButtonTapped), for: UIControl.Event.touchUpInside)
 | |
|         result.alpha = 0
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var durationStackView: UIStackView = {
 | |
|         let result: UIStackView = UIStackView()
 | |
|         result.axis = .horizontal
 | |
|         result.spacing = Values.smallSpacing
 | |
|         result.alignment = .center
 | |
|         
 | |
|         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)
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var durationLabel: UILabel = {
 | |
|         let result: UILabel = UILabel()
 | |
|         result.font = .systemFont(ofSize: Values.smallFontSize)
 | |
|         result.themeTextColor = .textPrimary
 | |
|         result.text = "0:00"
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var lockView = LockView()
 | |
| 
 | |
|     // MARK: - Settings
 | |
|     
 | |
|     private static let circleSize: CGFloat = 96
 | |
|     private static let pulseSize: CGFloat = 24
 | |
|     private static let iconSize: CGFloat = 28
 | |
|     private static let chevronSize: CGFloat = 16
 | |
|     private static let dotSize: CGFloat = 16
 | |
|     private static let lockViewHitMargin: CGFloat = 40
 | |
| 
 | |
|     // MARK: - Lifecycle
 | |
|     
 | |
|     init(voiceMessageButtonFrame: CGRect, delegate: VoiceMessageRecordingViewDelegate?) {
 | |
|         self.voiceMessageButtonFrame = voiceMessageButtonFrame
 | |
|         self.delegate = delegate
 | |
|         
 | |
|         super.init(frame: CGRect.zero)
 | |
|         
 | |
|         setUpViewHierarchy()
 | |
|         recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
 | |
|             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
 | |
|         let iconSize = VoiceMessageRecordingView.iconSize
 | |
|         addSubview(iconImageView)
 | |
|         
 | |
|         // Note: We intentionally pin to '.left' here as the frame calculations don't take
 | |
|         // LRT/RTL language direction into account
 | |
|         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)))
 | |
|         
 | |
|         // Circle
 | |
|         insertSubview(circleView, at: 0)
 | |
|         circleView.center(in: iconImageView)
 | |
|         
 | |
|         // Pulse
 | |
|         insertSubview(pulseView, at: 0)
 | |
|         pulseView.center(in: circleView)
 | |
|         
 | |
|         // Slide to cancel stack view
 | |
|         slideToCancelStackView.addArrangedSubview(chevronImageView)
 | |
|         slideToCancelStackView.addArrangedSubview(slideToCancelLabel)
 | |
|         addSubview(slideToCancelStackView)
 | |
|         slideToCancelStackViewTrailingConstraint.isActive = true
 | |
|         slideToCancelStackView.center(.vertical, in: iconImageView)
 | |
|         
 | |
|         // Cancel button
 | |
|         addSubview(cancelButton)
 | |
|         cancelButton.center(.horizontal, in: self)
 | |
|         cancelButton.center(.vertical, in: iconImageView)
 | |
|         
 | |
|         // Duration stack view
 | |
|         durationStackView.addArrangedSubview(dotView)
 | |
|         durationStackView.addArrangedSubview(durationLabel)
 | |
|         addSubview(durationStackView)
 | |
|         
 | |
|         durationStackView.pin(.leading, to: .leading, of: self, withInset: Values.largeSpacing)
 | |
|         durationStackView.center(.vertical, in: iconImageView)
 | |
|         
 | |
|         // Lock view
 | |
|         addSubview(lockView)
 | |
|         lockView.centerXAnchor.constraint(equalTo: iconImageView.centerXAnchor, constant: 2).isActive = true
 | |
|         lockViewBottomConstraint.isActive = true
 | |
|     }
 | |
| 
 | |
|     // MARK: - Updating
 | |
|     
 | |
|     @objc private func updateDurationLabel() {
 | |
|         let interval = Date().timeIntervalSince(recordingStartDate)
 | |
|         durationLabel.text = interval.formatted(format: .hoursMinutesSeconds)
 | |
|     }
 | |
| 
 | |
|     // MARK: - Animation
 | |
|     
 | |
|     func animate() {
 | |
|         layoutIfNeeded()
 | |
|         
 | |
|         slideToCancelStackViewTrailingConstraint.isActive = false
 | |
|         slideToCancelLabelCenterHorizontalConstraint.isActive = true
 | |
|         lockViewBottomConstraint.constant = -Values.mediumSpacing
 | |
|         
 | |
|         UIView.animate(withDuration: 0.25, animations: { [weak self] in
 | |
|             self?.alpha = 1
 | |
|             self?.layoutIfNeeded()
 | |
|         }, completion: { [weak self] _ in
 | |
|             self?.fadeOutDotView()
 | |
|             self?.pulse()
 | |
|         })
 | |
|     }
 | |
| 
 | |
|     private func fadeOutDotView() {
 | |
|         UIView.animate(withDuration: 0.5, animations: { [weak self] in
 | |
|             self?.dotView.alpha = 0
 | |
|         }, completion: { [weak self] _ in
 | |
|             self?.fadeInDotView()
 | |
|         })
 | |
|     }
 | |
| 
 | |
|     private func fadeInDotView() {
 | |
|         UIView.animate(withDuration: 0.5, animations: { [weak self] in
 | |
|             self?.dotView.alpha = 1
 | |
|         }, completion: { [weak self] _ in
 | |
|             self?.fadeOutDotView()
 | |
|         })
 | |
|     }
 | |
| 
 | |
|     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
 | |
|         
 | |
|         UIView.animate(withDuration: 1, animations: { [weak self] in
 | |
|             self?.layoutIfNeeded()
 | |
|             self?.pulseView.frame = expandedFrame
 | |
|             self?.pulseView.layer.cornerRadius = (expandedSize / 2)
 | |
|             self?.pulseView.alpha = 0
 | |
|         }, 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()
 | |
|         })
 | |
|     }
 | |
| 
 | |
|     // MARK: - Interaction
 | |
|     
 | |
|     func handleLongPressMoved(to location: CGPoint) {
 | |
|         if (!CurrentAppContext().isRTL && location.x < bounds.center.x) || (CurrentAppContext().isRTL && location.x > bounds.center.x) {
 | |
|             let translationX = location.x - bounds.center.x
 | |
|             let sign: CGFloat = (CurrentAppContext().isRTL ? 1 : -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
 | |
|             
 | |
|             chevronImageView.transform = CGAffineTransform(translationX: chevronX, y: 0)
 | |
|             slideToCancelLabel.transform = CGAffineTransform(translationX: labelX, y: 0)
 | |
|         }
 | |
|         else {
 | |
|             chevronImageView.transform = .identity
 | |
|             slideToCancelLabel.transform = .identity
 | |
|         }
 | |
|         
 | |
|         if isValidLockViewLocation(location) {
 | |
|             if !lockView.isExpanded {
 | |
|                 UIView.animate(withDuration: 0.25) {
 | |
|                     self.lockViewBottomConstraint.constant = -Values.mediumSpacing + LockView.expansionMargin
 | |
|                 }
 | |
|             }
 | |
|             lockView.expandIfNeeded()
 | |
|         }
 | |
|         else {
 | |
|             if lockView.isExpanded {
 | |
|                 UIView.animate(withDuration: 0.25) {
 | |
|                     self.lockViewBottomConstraint.constant = -Values.mediumSpacing
 | |
|                 }
 | |
|             }
 | |
|             lockView.collapseIfNeeded()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func handleLongPressEnded(at location: CGPoint) {
 | |
|         if pulseView.frame.contains(location) {
 | |
|             delegate?.endVoiceMessageRecording()
 | |
|         }
 | |
|         else if isValidLockViewLocation(location) {
 | |
|             let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleCircleViewTap))
 | |
|             circleView.addGestureRecognizer(tapGestureRecognizer)
 | |
|             
 | |
|             UIView.animate(withDuration: 0.25, delay: 0, options: .transitionCrossDissolve, animations: {
 | |
|                 self.lockView.alpha = 0
 | |
|                 self.iconImageView.image = UIImage(named: "ArrowUp")?.withRenderingMode(.alwaysTemplate)
 | |
|                 self.slideToCancelStackView.alpha = 0
 | |
|                 self.cancelButton.alpha = 1
 | |
|             }, completion: { _ in
 | |
|                 // Do nothing
 | |
|             })
 | |
|         }
 | |
|         else {
 | |
|             delegate?.cancelVoiceMessageRecording()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     @objc private func handleCircleViewTap() {
 | |
|         delegate?.endVoiceMessageRecording()
 | |
|     }
 | |
| 
 | |
|     @objc private func handleCancelButtonTapped() {
 | |
|         delegate?.cancelVoiceMessageRecording()
 | |
|     }
 | |
| 
 | |
|     // MARK: - Convenience
 | |
|     
 | |
|     private func isValidLockViewLocation(_ location: CGPoint) -> Bool {
 | |
|         let lockViewHitMargin = VoiceMessageRecordingView.lockViewHitMargin
 | |
|         
 | |
|         return location.y < 0 && location.x > (lockView.frame.minX - lockViewHitMargin) && location.x < (lockView.frame.maxX + lockViewHitMargin)
 | |
|     }
 | |
| }
 | |
| 
 | |
| // MARK: - Lock View
 | |
| 
 | |
| extension VoiceMessageRecordingView {
 | |
|     fileprivate final class LockView: UIView {
 | |
|         private lazy var widthConstraint = set(.width, to: LockView.width)
 | |
|         private(set) var isExpanded = false
 | |
| 
 | |
|         private lazy var stackView: UIStackView = {
 | |
|             let result: UIStackView = UIStackView()
 | |
|             result.axis = .vertical
 | |
|             result.spacing = Values.smallSpacing
 | |
|             result.alignment = .center
 | |
|             result.isLayoutMarginsRelativeArrangement = true
 | |
|             result.layoutMargins = UIEdgeInsets(top: 12, leading: 0, bottom: 8, trailing: 0)
 | |
|             
 | |
|             return result
 | |
|         }()
 | |
| 
 | |
|         private static let width: CGFloat = 44
 | |
|         static let expansionMargin: CGFloat = 3
 | |
|         private static let lockIconSize: CGFloat = 20
 | |
|         private static let chevronIconSize: CGFloat = 20
 | |
| 
 | |
|         override init(frame: CGRect) {
 | |
|             super.init(frame: frame)
 | |
|             
 | |
|             setUpViewHierarchy()
 | |
|         }
 | |
| 
 | |
|         required init?(coder: NSCoder) {
 | |
|             super.init(coder: coder)
 | |
|             
 | |
|             setUpViewHierarchy()
 | |
|         }
 | |
| 
 | |
|         private func setUpViewHierarchy() {
 | |
|             // Background & blur
 | |
|             let backgroundView: UIView = UIView()
 | |
|             backgroundView.themeBackgroundColor = .backgroundSecondary
 | |
|             backgroundView.alpha = Values.lowOpacity
 | |
|             addSubview(backgroundView)
 | |
|             backgroundView.pin(to: self)
 | |
|             
 | |
|             let blurView = UIVisualEffectView()
 | |
|             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)
 | |
|                 }
 | |
|             }
 | |
|             
 | |
|             // Size & shape
 | |
|             widthConstraint.isActive = true
 | |
|             layer.cornerRadius = (LockView.width / 2)
 | |
|             layer.masksToBounds = true
 | |
|             
 | |
|             // Border
 | |
|             themeBorderColor = .borderSeparator
 | |
|             layer.borderWidth = 1
 | |
|             
 | |
|             // 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)
 | |
|             stackView.addArrangedSubview(lockIconImageView)
 | |
|             
 | |
|             // 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)
 | |
|             stackView.addArrangedSubview(chevronIconImageView)
 | |
|             
 | |
|             // Stack view
 | |
|             addSubview(stackView)
 | |
|             stackView.pin(to: self)
 | |
|         }
 | |
| 
 | |
|         func expandIfNeeded() {
 | |
|             guard !isExpanded else { return }
 | |
|             
 | |
|             isExpanded = true
 | |
|             
 | |
|             let expansionMargin = LockView.expansionMargin
 | |
|             let newWidth = LockView.width + 2 * expansionMargin
 | |
|             
 | |
|             widthConstraint.constant = newWidth
 | |
|             
 | |
|             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 }
 | |
|             
 | |
|             isExpanded = false
 | |
|             
 | |
|             let newWidth = LockView.width
 | |
|             widthConstraint.constant = newWidth
 | |
|             
 | |
|             UIView.animate(withDuration: 0.25) {
 | |
|                 self.layer.cornerRadius = newWidth / 2
 | |
|                 self.stackView.layoutMargins = UIEdgeInsets(top: 12, leading: 0, bottom: 8, trailing: 0)
 | |
|                 self.layoutIfNeeded()
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| // MARK: - Delegate
 | |
| 
 | |
| protocol VoiceMessageRecordingViewDelegate: AnyObject {
 | |
|     func startVoiceMessageRecording()
 | |
|     func endVoiceMessageRecording()
 | |
|     func cancelVoiceMessageRecording()
 | |
| }
 |