mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-04-19 08:03:50 +02:00
#4096 - Added locked mode transition and animations, locked recording mode and real time waveform.
This commit is contained in:
@@ -20,16 +20,27 @@ protocol VoiceMessageToolbarViewDelegate: AnyObject {
|
||||
func voiceMessageToolbarViewDidRequestRecordingStart(_ toolbarView: VoiceMessageToolbarView)
|
||||
func voiceMessageToolbarViewDidRequestRecordingCancel(_ toolbarView: VoiceMessageToolbarView)
|
||||
func voiceMessageToolbarViewDidRequestRecordingFinish(_ toolbarView: VoiceMessageToolbarView)
|
||||
func voiceMessageToolbarViewDidRequestLockedModeRecording(_ toolbarView: VoiceMessageToolbarView)
|
||||
}
|
||||
|
||||
enum VoiceMessageToolbarViewUIState {
|
||||
case idle
|
||||
case recording
|
||||
case record
|
||||
case lockedModeRecord
|
||||
case lockedModePlayback
|
||||
}
|
||||
|
||||
struct VoiceMessageToolbarViewDetails {
|
||||
var state: VoiceMessageToolbarViewUIState = .idle
|
||||
var elapsedTime: String = ""
|
||||
var audioSamples: [Float] = []
|
||||
}
|
||||
|
||||
class VoiceMessageToolbarView: PassthroughView, Themable, UIGestureRecognizerDelegate {
|
||||
@IBOutlet private var backgroundView: UIView!
|
||||
|
||||
@IBOutlet private var recordingContainerView: UIView!
|
||||
|
||||
@IBOutlet private var recordButtonsContainerView: UIView!
|
||||
@IBOutlet private var primaryRecordButton: UIButton!
|
||||
@IBOutlet private var secondaryRecordButton: UIButton!
|
||||
@@ -37,43 +48,40 @@ class VoiceMessageToolbarView: PassthroughView, Themable, UIGestureRecognizerDel
|
||||
@IBOutlet private var recordingChromeContainerView: UIView!
|
||||
@IBOutlet private var recordingIndicatorView: UIView!
|
||||
|
||||
@IBOutlet private var elapsedTimeLabel: UILabel!
|
||||
|
||||
@IBOutlet private var slideToCancelContainerView: UIView!
|
||||
@IBOutlet private var slideToCancelLabel: UILabel!
|
||||
@IBOutlet private var slideToCancelChevron: UIImageView!
|
||||
@IBOutlet private var slideToCancelGradient: UIImageView!
|
||||
|
||||
@IBOutlet private var elapsedTimeLabel: UILabel!
|
||||
@IBOutlet private var lockContainerView: UIView!
|
||||
@IBOutlet private var lockContainerBackgroundView: UIView!
|
||||
@IBOutlet private var primaryLockButton: UIButton!
|
||||
@IBOutlet private var secondaryLockButton: UIButton!
|
||||
@IBOutlet private var lockChevron: UIView!
|
||||
|
||||
@IBOutlet private var lockedModeContainerView: UIView!
|
||||
@IBOutlet private var deleteButton: UIButton!
|
||||
@IBOutlet private var playbackViewContainerView: UIView!
|
||||
@IBOutlet private var sendButton: UIButton!
|
||||
|
||||
private var playbackView: VoiceMessagePlaybackView!
|
||||
|
||||
private var cancelLabelToRecordButtonDistance: CGFloat = 0.0
|
||||
private var lockChevronToRecordButtonDistance: CGFloat = 0.0
|
||||
private var lockChevronToLockButtonDistance: CGFloat = 0.0
|
||||
private var panDirection: UISwipeGestureRecognizer.Direction?
|
||||
|
||||
private var details: VoiceMessageToolbarViewDetails?
|
||||
|
||||
private var currentTheme: Theme? {
|
||||
didSet {
|
||||
updateUIAnimated(true)
|
||||
updateUIWithDetails(details, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
weak var delegate: VoiceMessageToolbarViewDelegate?
|
||||
|
||||
var state: VoiceMessageToolbarViewUIState = .idle {
|
||||
didSet {
|
||||
switch state {
|
||||
case .recording:
|
||||
let convertedFrame = self.convert(slideToCancelLabel.frame, from: slideToCancelContainerView)
|
||||
cancelLabelToRecordButtonDistance = recordButtonsContainerView.frame.minX - convertedFrame.maxX
|
||||
startAnimatingRecordingIndicator()
|
||||
case .idle:
|
||||
cancelDrag()
|
||||
}
|
||||
|
||||
updateUIAnimated(true)
|
||||
}
|
||||
}
|
||||
|
||||
var elapsedTime: String? {
|
||||
didSet {
|
||||
elapsedTimeLabel.text = elapsedTime
|
||||
}
|
||||
}
|
||||
|
||||
@objc static func instanceFromNib() -> VoiceMessageToolbarView {
|
||||
let nib = UINib(nibName: "VoiceMessageToolbarView", bundle: nil)
|
||||
@@ -87,6 +95,7 @@ class VoiceMessageToolbarView: PassthroughView, Themable, UIGestureRecognizerDel
|
||||
super.awakeFromNib()
|
||||
|
||||
slideToCancelGradient.image = Asset.Images.voiceMessageCancelGradient.image.withRenderingMode(.alwaysTemplate)
|
||||
lockContainerBackgroundView.layer.cornerRadius = lockContainerBackgroundView.bounds.width / 2.0
|
||||
|
||||
let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
|
||||
longPressGesture.delegate = self
|
||||
@@ -97,7 +106,52 @@ class VoiceMessageToolbarView: PassthroughView, Themable, UIGestureRecognizerDel
|
||||
longPressGesture.delegate = self
|
||||
recordButtonsContainerView.addGestureRecognizer(panGesture)
|
||||
|
||||
updateUIAnimated(false)
|
||||
playbackView = VoiceMessagePlaybackView.instanceFromNib()
|
||||
playbackViewContainerView.vc_addSubViewMatchingParent(playbackView)
|
||||
|
||||
updateUIWithDetails(VoiceMessageToolbarViewDetails(), animated: false)
|
||||
}
|
||||
|
||||
func configureWithDetails(_ details: VoiceMessageToolbarViewDetails) {
|
||||
elapsedTimeLabel.text = details.elapsedTime
|
||||
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.updatePlaybackViewWithDetails(details)
|
||||
}
|
||||
|
||||
if self.details?.state != details.state {
|
||||
switch details.state {
|
||||
case .record:
|
||||
var convertedFrame = self.convert(slideToCancelLabel.frame, from: slideToCancelContainerView)
|
||||
cancelLabelToRecordButtonDistance = recordButtonsContainerView.frame.minX - convertedFrame.maxX
|
||||
|
||||
convertedFrame = self.convert(lockChevron.frame, from: lockContainerView)
|
||||
lockChevronToRecordButtonDistance = recordButtonsContainerView.frame.midY + convertedFrame.maxY
|
||||
|
||||
lockChevronToLockButtonDistance = lockChevron.frame.minY - primaryLockButton.frame.midY
|
||||
|
||||
startAnimatingRecordingIndicator()
|
||||
default:
|
||||
cancelDrag()
|
||||
}
|
||||
|
||||
if details.state == .lockedModeRecord && self.details?.state == .record {
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.secondaryLockButton.transform = CGAffineTransform(scaleX: 0.1, y: 0.1)
|
||||
self.secondaryLockButton.alpha = 0.0
|
||||
} completion: { _ in
|
||||
self.updateUIWithDetails(details, animated: true)
|
||||
}
|
||||
} else {
|
||||
updateUIWithDetails(details, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
self.details = details
|
||||
}
|
||||
|
||||
func getRequiredNumberOfSamples() -> Int {
|
||||
return playbackView.getRequiredNumberOfSamples()
|
||||
}
|
||||
|
||||
// MARK: - Themable
|
||||
@@ -119,8 +173,7 @@ class VoiceMessageToolbarView: PassthroughView, Themable, UIGestureRecognizerDel
|
||||
case UIGestureRecognizer.State.began:
|
||||
delegate?.voiceMessageToolbarViewDidRequestRecordingStart(self)
|
||||
case UIGestureRecognizer.State.ended:
|
||||
delegate?.voiceMessageToolbarViewDidRequestRecordingFinish(self)
|
||||
case UIGestureRecognizer.State.cancelled:
|
||||
// delegate?.voiceMessageToolbarViewDidRequestRecordingFinish(self)
|
||||
delegate?.voiceMessageToolbarViewDidRequestRecordingCancel(self)
|
||||
default:
|
||||
break
|
||||
@@ -128,17 +181,49 @@ class VoiceMessageToolbarView: PassthroughView, Themable, UIGestureRecognizerDel
|
||||
}
|
||||
|
||||
@objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
|
||||
guard self.state == .recording && gestureRecognizer.state == .changed else {
|
||||
guard details?.state == .record && gestureRecognizer.state == .changed else {
|
||||
return
|
||||
}
|
||||
|
||||
let translation = gestureRecognizer.translation(in: self)
|
||||
|
||||
secondaryRecordButton.transform = CGAffineTransform(translationX: min(translation.x, 0.0), y: 0.0)
|
||||
slideToCancelContainerView.transform = CGAffineTransform(translationX: min(translation.x + cancelLabelToRecordButtonDistance, 0.0), y: 0.0)
|
||||
if abs(translation.x) <= 20.0 && abs(translation.y) <= 20.0 {
|
||||
panDirection = nil
|
||||
} else if panDirection == nil {
|
||||
if abs(translation.x) >= abs(translation.y) {
|
||||
panDirection = .left
|
||||
} else {
|
||||
panDirection = .up
|
||||
}
|
||||
}
|
||||
|
||||
if abs(translation.x - recordButtonsContainerView.frame.width / 2.0) > self.bounds.width / 2.0 {
|
||||
cancelDrag()
|
||||
if panDirection == .left {
|
||||
secondaryRecordButton.transform = CGAffineTransform(translationX: min(translation.x, 0.0), y: 0.0)
|
||||
slideToCancelContainerView.transform = CGAffineTransform(translationX: min(translation.x + cancelLabelToRecordButtonDistance, 0.0), y: 0.0)
|
||||
|
||||
if abs(translation.x - recordButtonsContainerView.frame.width / 2.0) > self.bounds.width / 2.0 {
|
||||
delegate?.voiceMessageToolbarViewDidRequestRecordingCancel(self)
|
||||
}
|
||||
} else if panDirection == .up {
|
||||
secondaryRecordButton.transform = CGAffineTransform(translationX: 0.0, y: min(0.0, translation.y))
|
||||
|
||||
let yTranslation = min(max(translation.y + lockChevronToRecordButtonDistance, -lockChevronToLockButtonDistance), 0.0)
|
||||
lockChevron.transform = CGAffineTransform(translationX: 0.0, y: yTranslation)
|
||||
|
||||
let transitionPercentage = abs(yTranslation) / lockChevronToLockButtonDistance
|
||||
|
||||
lockChevron.alpha = 1.0 - transitionPercentage
|
||||
secondaryRecordButton.alpha = 1.0 - transitionPercentage
|
||||
primaryLockButton.alpha = 1.0 - transitionPercentage
|
||||
lockContainerBackgroundView.alpha = 1.0 - transitionPercentage
|
||||
secondaryLockButton.alpha = transitionPercentage
|
||||
|
||||
if transitionPercentage >= 1.0 {
|
||||
self.delegate?.voiceMessageToolbarViewDidRequestLockedModeRecording(self)
|
||||
}
|
||||
|
||||
} else {
|
||||
secondaryRecordButton.transform = CGAffineTransform(translationX: min(0.0, translation.x), y: min(0.0, translation.y))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,19 +234,42 @@ class VoiceMessageToolbarView: PassthroughView, Themable, UIGestureRecognizerDel
|
||||
}
|
||||
}
|
||||
|
||||
private func updateUIAnimated(_ animated: Bool) {
|
||||
UIView.animate(withDuration: (animated ? 0.25 : 0.0)) {
|
||||
switch self.state {
|
||||
case .idle:
|
||||
self.backgroundView.alpha = 0.0
|
||||
self.primaryRecordButton.alpha = 1.0
|
||||
self.secondaryRecordButton.alpha = 0.0
|
||||
self.recordingChromeContainerView.alpha = 0.0
|
||||
case .recording:
|
||||
private func updateUIWithDetails(_ details: VoiceMessageToolbarViewDetails?, animated: Bool) {
|
||||
guard let details = details else {
|
||||
return
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: (animated ? 0.25 : 0.0), delay: 0.0, options: .beginFromCurrentState) {
|
||||
switch details.state {
|
||||
case .record:
|
||||
self.backgroundView.alpha = 1.0
|
||||
self.primaryRecordButton.alpha = 0.0
|
||||
self.secondaryRecordButton.alpha = 1.0
|
||||
self.recordingChromeContainerView.alpha = 1.0
|
||||
self.lockContainerView.alpha = 1.0
|
||||
self.lockContainerBackgroundView.alpha = 1.0
|
||||
self.lockedModeContainerView.alpha = 0.0
|
||||
self.recordingContainerView.alpha = 1.0
|
||||
case .lockedModeRecord:
|
||||
self.backgroundView.alpha = 1.0
|
||||
self.primaryRecordButton.alpha = 0.0
|
||||
self.secondaryRecordButton.alpha = 0.0
|
||||
self.recordingChromeContainerView.alpha = 0.0
|
||||
self.lockContainerView.alpha = 0.0
|
||||
self.lockedModeContainerView.alpha = 1.0
|
||||
self.recordingContainerView.alpha = 0.0
|
||||
default:
|
||||
self.backgroundView.alpha = 0.0
|
||||
self.primaryRecordButton.alpha = 1.0
|
||||
self.secondaryRecordButton.alpha = 0.0
|
||||
self.recordingChromeContainerView.alpha = 0.0
|
||||
self.lockContainerView.alpha = 0.0
|
||||
self.lockContainerBackgroundView.alpha = 1.0
|
||||
self.primaryLockButton.alpha = 1.0
|
||||
self.secondaryLockButton.alpha = 0.0
|
||||
self.lockChevron.alpha = 1.0
|
||||
self.lockedModeContainerView.alpha = 0.0
|
||||
self.recordingContainerView.alpha = 1.0
|
||||
}
|
||||
|
||||
guard let theme = self.currentTheme else {
|
||||
@@ -176,18 +284,30 @@ class VoiceMessageToolbarView: PassthroughView, Themable, UIGestureRecognizerDel
|
||||
self.slideToCancelChevron.tintColor = theme.textSecondaryColor
|
||||
self.elapsedTimeLabel.textColor = theme.textSecondaryColor
|
||||
} completion: { _ in
|
||||
switch self.state {
|
||||
switch details.state {
|
||||
case .idle:
|
||||
self.secondaryRecordButton.transform = .identity
|
||||
self.slideToCancelContainerView.transform = .identity
|
||||
self.lockChevron.transform = .identity
|
||||
self.secondaryLockButton.transform = .identity
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updatePlaybackViewWithDetails(_ details: VoiceMessageToolbarViewDetails) {
|
||||
var playbackViewDetails = VoiceMessagePlaybackViewDetails()
|
||||
playbackViewDetails.recording = (details.state == .record || details.state == .lockedModeRecord)
|
||||
playbackViewDetails.currentTime = details.elapsedTime
|
||||
playbackViewDetails.samples = details.audioSamples
|
||||
playbackViewDetails.playbackEnabled = true
|
||||
playbackViewDetails.progress = 0.0
|
||||
playbackView.configureWithDetails(playbackViewDetails)
|
||||
}
|
||||
|
||||
private func startAnimatingRecordingIndicator() {
|
||||
if self.state != .recording {
|
||||
if self.details?.state != .record {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -202,4 +322,12 @@ class VoiceMessageToolbarView: PassthroughView, Themable, UIGestureRecognizerDel
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@IBAction private func onTrashButtonTap(_ sender: UIBarItem) {
|
||||
delegate?.voiceMessageToolbarViewDidRequestRecordingCancel(self)
|
||||
}
|
||||
|
||||
@IBAction private func onSendButtonTap(_ sender: UIBarItem) {
|
||||
delegate?.voiceMessageToolbarViewDidRequestRecordingFinish(self)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user