Redesign voice message recording UI

pull/347/head
nielsandriesse 3 years ago
parent 4ab0efd512
commit 3e7de541cb

@ -564,7 +564,7 @@
C364535C252467900045C478 /* AudioUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C364535B252467900045C478 /* AudioUtilities.swift */; };
C374EEE225DA26740073A857 /* LinkPreviewModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEE125DA26740073A857 /* LinkPreviewModal.swift */; };
C374EEEB25DA3CA70073A857 /* ConversationTitleViewV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEEA25DA3CA70073A857 /* ConversationTitleViewV2.swift */; };
C374EEF425DB31D40073A857 /* VoiceMessageOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEF325DB31D40073A857 /* VoiceMessageOverlay.swift */; };
C374EEF425DB31D40073A857 /* VoiceMessageRecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */; };
C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C379DCF3256735770002D4EB /* VisibleMessage+Attachment.swift */; };
C37F5385255B94F6002AEA92 /* SelectRecipientViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF34E255B6DC8007E1867 /* SelectRecipientViewController.h */; settings = {ATTRIBUTES = (Public, ); }; };
C37F5396255B95BD002AEA92 /* OWSAnyTouchGestureRecognizer.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF302255B6DBE007E1867 /* OWSAnyTouchGestureRecognizer.h */; settings = {ATTRIBUTES = (Public, ); }; };
@ -1609,7 +1609,7 @@
C364535B252467900045C478 /* AudioUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioUtilities.swift; sourceTree = "<group>"; };
C374EEE125DA26740073A857 /* LinkPreviewModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewModal.swift; sourceTree = "<group>"; };
C374EEEA25DA3CA70073A857 /* ConversationTitleViewV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTitleViewV2.swift; sourceTree = "<group>"; };
C374EEF325DB31D40073A857 /* VoiceMessageOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageOverlay.swift; sourceTree = "<group>"; };
C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingView.swift; sourceTree = "<group>"; };
C379DCF3256735770002D4EB /* VisibleMessage+Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Attachment.swift"; sourceTree = "<group>"; };
C379DCFD25673DBC0002D4EB /* TSAttachmentPointer+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSAttachmentPointer+Conversion.swift"; sourceTree = "<group>"; };
C37F53E8255BA9BB002AEA92 /* Environment.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Environment.h; sourceTree = "<group>"; };
@ -2291,7 +2291,7 @@
B8269D2825C7A4B400488AB4 /* InputView.swift */,
B8269D3225C7A8C600488AB4 /* InputViewButton.swift */,
B8269D3C25C7B34D00488AB4 /* InputTextView.swift */,
C374EEF325DB31D40073A857 /* VoiceMessageOverlay.swift */,
C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */,
);
path = "Input View";
sourceTree = "<group>";
@ -5017,7 +5017,7 @@
34129B8621EF877A005457A8 /* LinkPreviewView.swift in Sources */,
34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */,
451166C01FD86B98000739BA /* AccountManager.swift in Sources */,
C374EEF425DB31D40073A857 /* VoiceMessageOverlay.swift in Sources */,
C374EEF425DB31D40073A857 /* VoiceMessageRecordingView.swift in Sources */,
B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */,
3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */,
340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */,

@ -3,7 +3,7 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
private let delegate: InputViewDelegate
var quoteDraftInfo: (model: OWSQuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } }
var linkPreviewInfo: (url: String, draft: OWSLinkPreviewDraft?)?
private var voiceMessageOverlay: VoiceMessageOverlay?
private var voiceMessageRecordingView: VoiceMessageRecordingView?
private lazy var linkPreviewView: LinkPreviewViewV2 = {
let maxWidth = self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset
@ -212,14 +212,14 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
}
@objc private func showVoiceMessageUI() {
voiceMessageOverlay?.removeFromSuperview()
voiceMessageRecordingView?.removeFromSuperview()
let voiceMessageButtonFrame = voiceMessageButton.superview!.convert(voiceMessageButton.frame, to: self)
let voiceMessageOverlay = VoiceMessageOverlay(voiceMessageButtonFrame: voiceMessageButtonFrame)
voiceMessageOverlay.alpha = 0
addSubview(voiceMessageOverlay)
voiceMessageOverlay.pin(to: self)
self.voiceMessageOverlay = voiceMessageOverlay
voiceMessageOverlay.animate()
let voiceMessageRecordingView = VoiceMessageRecordingView(voiceMessageButtonFrame: voiceMessageButtonFrame)
voiceMessageRecordingView.alpha = 0
addSubview(voiceMessageRecordingView)
voiceMessageRecordingView.pin(to: self)
self.voiceMessageRecordingView = voiceMessageRecordingView
voiceMessageRecordingView.animate()
let allOtherViews = [ cameraButton, libraryButton, gifButton, documentButton, sendButton, inputTextView, additionalContentContainer ]
UIView.animate(withDuration: 0.25) {
allOtherViews.forEach { $0.alpha = 0 }

@ -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()
})
}
}

@ -51,7 +51,7 @@ NS_ASSUME_NONNULL_BEGIN
if (hours > 0) {
return [NSString stringWithFormat:@"%ld:%02ld:%02ld", hours, minutes, seconds];
} else {
return [NSString stringWithFormat:@"%ld:%02ld", minutes, seconds];
return [NSString stringWithFormat:@"%02ld:%02ld", minutes, seconds];
}
}

Loading…
Cancel
Save