mirror of https://github.com/oxen-io/session-ios
Redesign voice message recording UI
parent
4ab0efd512
commit
3e7de541cb
@ -1,123 +0,0 @@
|
||||
|
||||
final class VoiceMessageOverlay : UIView {
|
||||
private let voiceMessageButtonFrame: CGRect
|
||||
private lazy var slideToCancelStackViewRightConstraint = slideToCancelStackView.pin(.right, to: .right, of: self)
|
||||
private lazy var slideToCancelStackViewCenterHorizontalConstraint = slideToCancelStackView.center(.horizontal, in: self)
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var slideToCancelStackView: UIStackView = {
|
||||
let result = UIStackView()
|
||||
result.axis = .horizontal
|
||||
result.spacing = Values.smallSpacing
|
||||
result.alpha = 0
|
||||
result.alignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var durationStackView: UIStackView = {
|
||||
let result = UIStackView()
|
||||
result.axis = .horizontal
|
||||
result.spacing = 4
|
||||
result.alpha = 0
|
||||
result.alignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var durationLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = Colors.destructive
|
||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.text = "00:12"
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
private static let circleSize: CGFloat = 100
|
||||
private static let iconSize: CGFloat = 28
|
||||
private static let chevronSize: CGFloat = 20
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(voiceMessageButtonFrame: CGRect) {
|
||||
self.voiceMessageButtonFrame = voiceMessageButtonFrame
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(voiceMessageButtonFrame:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(voiceMessageButtonFrame:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
let iconSize = VoiceMessageOverlay.iconSize
|
||||
// Icon
|
||||
let iconImageView = UIImageView()
|
||||
iconImageView.image = UIImage(named: "Microphone")!.withTint(.white)
|
||||
iconImageView.contentMode = .scaleAspectFit
|
||||
iconImageView.set(.width, to: iconSize)
|
||||
iconImageView.set(.height, to: iconSize)
|
||||
addSubview(iconImageView)
|
||||
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
|
||||
let circleView = UIView()
|
||||
circleView.backgroundColor = Colors.destructive
|
||||
let circleSize = VoiceMessageOverlay.circleSize
|
||||
circleView.set(.width, to: circleSize)
|
||||
circleView.set(.height, to: circleSize)
|
||||
circleView.layer.cornerRadius = circleSize / 2
|
||||
circleView.layer.masksToBounds = true
|
||||
insertSubview(circleView, at: 0)
|
||||
circleView.center(in: iconImageView)
|
||||
// Slide to cancel stack view
|
||||
let chevronSize = VoiceMessageOverlay.chevronSize
|
||||
let chevronLeft1 = UIImageView(image: UIImage(named: "small_chevron_left")!.withTint(Colors.destructive))
|
||||
chevronLeft1.contentMode = .scaleAspectFit
|
||||
chevronLeft1.set(.width, to: chevronSize)
|
||||
chevronLeft1.set(.height, to: chevronSize)
|
||||
slideToCancelStackView.addArrangedSubview(chevronLeft1)
|
||||
let slideToCancelLabel = UILabel()
|
||||
slideToCancelLabel.text = "Slide to cancel"
|
||||
slideToCancelLabel.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
slideToCancelLabel.textColor = Colors.destructive
|
||||
slideToCancelStackView.addArrangedSubview(slideToCancelLabel)
|
||||
let chevronLeft2 = UIImageView(image: UIImage(named: "small_chevron_left")!.withTint(Colors.destructive))
|
||||
chevronLeft2.contentMode = .scaleAspectFit
|
||||
chevronLeft2.set(.width, to: chevronSize)
|
||||
chevronLeft2.set(.height, to: chevronSize)
|
||||
slideToCancelStackView.addArrangedSubview(chevronLeft2)
|
||||
addSubview(slideToCancelStackView)
|
||||
slideToCancelStackViewRightConstraint.isActive = true
|
||||
slideToCancelStackView.center(.vertical, in: iconImageView)
|
||||
// Duration stack view
|
||||
let microphoneImageView = UIImageView()
|
||||
microphoneImageView.image = UIImage(named: "Microphone")!.withTint(Colors.destructive)
|
||||
microphoneImageView.contentMode = .scaleAspectFit
|
||||
microphoneImageView.set(.width, to: iconSize)
|
||||
microphoneImageView.set(.height, to: iconSize)
|
||||
durationStackView.addArrangedSubview(microphoneImageView)
|
||||
durationStackView.addArrangedSubview(durationLabel)
|
||||
addSubview(durationStackView)
|
||||
durationStackView.pin(.left, to: .left, of: self, withInset: Values.largeSpacing)
|
||||
durationStackView.center(.vertical, in: iconImageView)
|
||||
}
|
||||
|
||||
// MARK: Animation
|
||||
func animate() {
|
||||
UIView.animate(withDuration: 0.15, animations: {
|
||||
self.alpha = 1
|
||||
}, completion: { _ in
|
||||
self.slideToCancelStackViewRightConstraint.isActive = false
|
||||
self.slideToCancelStackViewCenterHorizontalConstraint.isActive = true
|
||||
UIView.animate(withDuration: 0.15) {
|
||||
self.slideToCancelStackView.alpha = 1
|
||||
self.durationStackView.alpha = 1
|
||||
self.layoutIfNeeded()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,203 @@
|
||||
|
||||
final class VoiceMessageRecordingView : UIView {
|
||||
private let voiceMessageButtonFrame: CGRect
|
||||
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)
|
||||
private let recordingStartDate = Date()
|
||||
private var recordingTimer: Timer?
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var pulseView: UIView = {
|
||||
let result = UIView()
|
||||
result.backgroundColor = Colors.destructive
|
||||
result.layer.cornerRadius = VoiceMessageRecordingView.circleSize / 2
|
||||
result.layer.masksToBounds = true
|
||||
result.alpha = 0.5
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var slideToCancelStackView: UIStackView = {
|
||||
let result = UIStackView()
|
||||
result.axis = .horizontal
|
||||
result.spacing = Values.smallSpacing
|
||||
result.alignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var slideToCancelLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.text = "Slide to cancel"
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var durationStackView: UIStackView = {
|
||||
let result = UIStackView()
|
||||
result.axis = .horizontal
|
||||
result.spacing = Values.smallSpacing
|
||||
result.alignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var dotView: UIView = {
|
||||
let result = UIView()
|
||||
result.backgroundColor = Colors.destructive
|
||||
let dotSize = VoiceMessageRecordingView.dotSize
|
||||
result.set(.width, to: dotSize)
|
||||
result.set(.height, to: dotSize)
|
||||
result.layer.cornerRadius = dotSize / 2
|
||||
result.layer.masksToBounds = true
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var durationLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.text = "00:00"
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
private static let circleSize: CGFloat = 96
|
||||
private static let pulseSize: CGFloat = 24
|
||||
private static let microPhoneIconSize: CGFloat = 28
|
||||
private static let chevronSize: CGFloat = 16
|
||||
private static let dotSize: CGFloat = 16
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(voiceMessageButtonFrame: CGRect) {
|
||||
self.voiceMessageButtonFrame = voiceMessageButtonFrame
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
recordingTimer = Timer.scheduledTimer(withTimeInterval: 1, 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.microPhoneIconSize
|
||||
let iconImageView = UIImageView()
|
||||
iconImageView.image = UIImage(named: "Microphone")!.withTint(.white)
|
||||
iconImageView.contentMode = .scaleAspectFit
|
||||
iconImageView.set(.width, to: iconSize)
|
||||
iconImageView.set(.height, to: iconSize)
|
||||
addSubview(iconImageView)
|
||||
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
|
||||
let circleView = UIView()
|
||||
circleView.backgroundColor = Colors.destructive
|
||||
let circleSize = VoiceMessageRecordingView.circleSize
|
||||
circleView.set(.width, to: circleSize)
|
||||
circleView.set(.height, to: circleSize)
|
||||
circleView.layer.cornerRadius = circleSize / 2
|
||||
circleView.layer.masksToBounds = true
|
||||
insertSubview(circleView, at: 0)
|
||||
circleView.center(in: iconImageView)
|
||||
// Pulse
|
||||
insertSubview(pulseView, at: 0)
|
||||
pulseView.center(in: circleView)
|
||||
// Slide to cancel stack view
|
||||
let chevronSize = VoiceMessageRecordingView.chevronSize
|
||||
let chevronColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
let chevronImageView = UIImageView(image: UIImage(named: "small_chevron_left")!.withTint(chevronColor))
|
||||
chevronImageView.contentMode = .scaleAspectFit
|
||||
chevronImageView.set(.width, to: chevronSize)
|
||||
chevronImageView.set(.height, to: chevronSize)
|
||||
slideToCancelStackView.addArrangedSubview(chevronImageView)
|
||||
slideToCancelStackView.addArrangedSubview(slideToCancelLabel)
|
||||
addSubview(slideToCancelStackView)
|
||||
slideToCancelStackViewRightConstraint.isActive = true
|
||||
slideToCancelStackView.center(.vertical, in: iconImageView)
|
||||
// Duration stack view
|
||||
durationStackView.addArrangedSubview(dotView)
|
||||
durationStackView.addArrangedSubview(durationLabel)
|
||||
addSubview(durationStackView)
|
||||
durationStackView.pin(.left, to: .left, of: self, withInset: Values.largeSpacing)
|
||||
durationStackView.center(.vertical, in: iconImageView)
|
||||
// Lock view
|
||||
let lockView = UIView()
|
||||
lockView.backgroundColor = .blue
|
||||
lockView.set(.width, to: 60)
|
||||
lockView.set(.height, to: 60)
|
||||
addSubview(lockView)
|
||||
lockView.pin(.bottom, to: .top, of: self, withInset: -40)
|
||||
lockView.center(.horizontal, in: iconImageView)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
@objc private func updateDurationLabel() {
|
||||
let interval = Date().timeIntervalSince(recordingStartDate)
|
||||
durationLabel.text = OWSFormat.formatDurationSeconds(Int(interval))
|
||||
}
|
||||
|
||||
// MARK: Animation
|
||||
func animate() {
|
||||
layoutIfNeeded()
|
||||
self.slideToCancelStackViewRightConstraint.isActive = false
|
||||
self.slideToCancelLabelCenterHorizontalConstraint.isActive = true
|
||||
UIView.animate(withDuration: 0.25, animations: {
|
||||
self.alpha = 1
|
||||
self.layoutIfNeeded()
|
||||
}, completion: { _ in
|
||||
self.fadeOutDotView()
|
||||
self.pulse()
|
||||
})
|
||||
}
|
||||
|
||||
private func fadeOutDotView() {
|
||||
UIView.animate(withDuration: 0.5, animations: {
|
||||
self.dotView.alpha = 0
|
||||
}, completion: { _ in
|
||||
self.fadeInDotView()
|
||||
})
|
||||
}
|
||||
|
||||
private func fadeInDotView() {
|
||||
UIView.animate(withDuration: 0.5, animations: {
|
||||
self.dotView.alpha = 1
|
||||
}, completion: { _ 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: {
|
||||
self.layoutIfNeeded()
|
||||
self.pulseView.frame = expandedFrame
|
||||
self.pulseView.layer.cornerRadius = expandedSize / 2
|
||||
self.pulseView.alpha = 0
|
||||
}, completion: { _ 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()
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue