Partially implement voice message recording UI

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

@ -564,6 +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 */; };
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, ); }; };
@ -1608,6 +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>"; };
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>"; };
@ -2289,6 +2291,7 @@
B8269D2825C7A4B400488AB4 /* InputView.swift */,
B8269D3225C7A8C600488AB4 /* InputViewButton.swift */,
B8269D3C25C7B34D00488AB4 /* InputTextView.swift */,
C374EEF325DB31D40073A857 /* VoiceMessageOverlay.swift */,
);
path = "Input View";
sourceTree = "<group>";
@ -5014,6 +5017,7 @@
34129B8621EF877A005457A8 /* LinkPreviewView.swift in Sources */,
34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */,
451166C01FD86B98000739BA /* AccountManager.swift in Sources */,
C374EEF425DB31D40073A857 /* VoiceMessageOverlay.swift in Sources */,
B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */,
3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */,
340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */,

@ -3,6 +3,7 @@
// Tapping replies
// Mentions
// Remaining send logic
// Recording voice messages
// Slight paging glitch
// Scrolling bug
// Scroll button bug

@ -52,6 +52,7 @@ public final class InputTextView : UITextView, UITextViewDelegate {
// MARK: Updating
public func textViewDidChange(_ textView: UITextView) {
defer { snDelegate.inputTextViewDidChangeContent(self) }
placeholderLabel.isHidden = !text.isEmpty
let width = frame.width
let height = frame.height
@ -69,4 +70,5 @@ public final class InputTextView : UITextView, UITextViewDelegate {
protocol InputTextViewDelegate {
func inputTextViewDidChangeSize(_ inputTextView: InputTextView)
func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
}

@ -3,6 +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 lazy var linkPreviewView: LinkPreviewViewV2 = {
let maxWidth = self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset
@ -21,7 +22,12 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
private lazy var libraryButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_roll_black"), delegate: self)
private lazy var gifButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_gif_black"), delegate: self)
private lazy var documentButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_document_black"), delegate: self)
private lazy var sendButton = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self)
private lazy var voiceMessageButton = InputViewButton(icon: #imageLiteral(resourceName: "Microphone"), delegate: self)
private lazy var sendButton: InputViewButton = {
let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self)
result.alpha = 0
return result
}()
private lazy var inputTextView = InputTextView(delegate: self)
@ -94,11 +100,21 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
mainStackView.pin(.top, to: .bottom, of: separator)
mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self)
mainStackView.pin(.bottom, to: .bottom, of: self, withInset: -2)
// Voice message button
let voiceMessageButtonContainer = container(for: voiceMessageButton)
addSubview(voiceMessageButtonContainer)
voiceMessageButtonContainer.center(in: sendButton)
}
// MARK: Updating
func inputTextViewDidChangeSize(_ inputTextView: InputTextView) {
invalidateIntrinsicContentSize()
}
func inputTextViewDidChangeContent(_ inputTextView: InputTextView) {
let hasText = !text.isEmpty
sendButton.alpha = hasText ? 1 : 0
voiceMessageButton.alpha = hasText ? 0 : 1
autoGenerateLinkPreviewIfPossible()
}
@ -174,6 +190,10 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
if inputViewButton == sendButton { delegate.handleSendButtonTapped() }
}
func handleInputViewButtonLongPressed(_ inputViewButton: InputViewButton) {
if inputViewButton == voiceMessageButton { showVoiceMessageUI() }
}
func handleQuoteViewCancelButtonTapped() {
delegate.handleQuoteViewCancelButtonTapped()
}
@ -190,6 +210,21 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
linkPreviewInfo = nil
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
}
@objc private func showVoiceMessageUI() {
voiceMessageOverlay?.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 allOtherViews = [ cameraButton, libraryButton, gifButton, documentButton, sendButton, inputTextView, additionalContentContainer ]
UIView.animate(withDuration: 0.25) {
allOtherViews.forEach { $0.alpha = 0 }
}
}
}
// MARK: Delegate

@ -5,10 +5,13 @@ final class InputViewButton : UIView {
private let delegate: InputViewButtonDelegate
private lazy var widthConstraint = set(.width, to: InputViewButton.size)
private lazy var heightConstraint = set(.height, to: InputViewButton.size)
private var longPressTimer: Timer?
private var isLongPress = false
// MARK: Settings
static let size = CGFloat(40)
static let expandedSize = CGFloat(48)
static let iconSize: CGFloat = 20
// MARK: Lifecycle
init(icon: UIImage, isSendButton: Bool = false, delegate: InputViewButtonDelegate) {
@ -37,8 +40,9 @@ final class InputViewButton : UIView {
let tint = isSendButton ? UIColor.black : Colors.text
let iconImageView = UIImageView(image: icon.withTint(tint))
iconImageView.contentMode = .scaleAspectFit
iconImageView.set(.width, to: 20)
iconImageView.set(.height, to: 20)
let iconSize = InputViewButton.iconSize
iconImageView.set(.width, to: iconSize)
iconImageView.set(.height, to: iconSize)
addSubview(iconImageView)
iconImageView.center(in: self)
}
@ -71,15 +75,30 @@ final class InputViewButton : UIView {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
expand()
invalidateLongPressIfNeeded()
longPressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false, block: { [weak self] _ in
guard let self = self else { return }
self.isLongPress = true
self.delegate.handleInputViewButtonLongPressed(self)
})
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
collapse()
delegate.handleInputViewButtonTapped(self)
if !isLongPress {
delegate.handleInputViewButtonTapped(self)
}
invalidateLongPressIfNeeded()
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
collapse()
invalidateLongPressIfNeeded()
}
private func invalidateLongPressIfNeeded() {
longPressTimer?.invalidate()
isLongPress = false
}
}
@ -87,4 +106,5 @@ final class InputViewButton : UIView {
protocol InputViewButtonDelegate {
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton)
func handleInputViewButtonLongPressed(_ inputViewButton: InputViewButton)
}

@ -0,0 +1,123 @@
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()
}
})
}
}
Loading…
Cancel
Save