From 3e7de541cbccb71f89743efcf1b062aff0bd4e22 Mon Sep 17 00:00:00 2001 From: nielsandriesse Date: Tue, 16 Feb 2021 13:57:30 +1100 Subject: [PATCH] Redesign voice message recording UI --- Session.xcodeproj/project.pbxproj | 8 +- .../Input View/InputView.swift | 16 +- .../Input View/VoiceMessageOverlay.swift | 123 ----------- .../VoiceMessageRecordingView.swift | 203 ++++++++++++++++++ SignalUtilitiesKit/Utilities/OWSFormat.m | 2 +- 5 files changed, 216 insertions(+), 136 deletions(-) delete mode 100644 Session/Conversations V2/Input View/VoiceMessageOverlay.swift create mode 100644 Session/Conversations V2/Input View/VoiceMessageRecordingView.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index bda67a89b..97f6ed7cb 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -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 = ""; }; C374EEE125DA26740073A857 /* LinkPreviewModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewModal.swift; sourceTree = ""; }; C374EEEA25DA3CA70073A857 /* ConversationTitleViewV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTitleViewV2.swift; sourceTree = ""; }; - C374EEF325DB31D40073A857 /* VoiceMessageOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageOverlay.swift; sourceTree = ""; }; + C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingView.swift; sourceTree = ""; }; C379DCF3256735770002D4EB /* VisibleMessage+Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Attachment.swift"; sourceTree = ""; }; C379DCFD25673DBC0002D4EB /* TSAttachmentPointer+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSAttachmentPointer+Conversion.swift"; sourceTree = ""; }; C37F53E8255BA9BB002AEA92 /* Environment.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Environment.h; sourceTree = ""; }; @@ -2291,7 +2291,7 @@ B8269D2825C7A4B400488AB4 /* InputView.swift */, B8269D3225C7A8C600488AB4 /* InputViewButton.swift */, B8269D3C25C7B34D00488AB4 /* InputTextView.swift */, - C374EEF325DB31D40073A857 /* VoiceMessageOverlay.swift */, + C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */, ); path = "Input View"; sourceTree = ""; @@ -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 */, diff --git a/Session/Conversations V2/Input View/InputView.swift b/Session/Conversations V2/Input View/InputView.swift index 83a721289..b06d2ef27 100644 --- a/Session/Conversations V2/Input View/InputView.swift +++ b/Session/Conversations V2/Input View/InputView.swift @@ -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 } diff --git a/Session/Conversations V2/Input View/VoiceMessageOverlay.swift b/Session/Conversations V2/Input View/VoiceMessageOverlay.swift deleted file mode 100644 index cea893b85..000000000 --- a/Session/Conversations V2/Input View/VoiceMessageOverlay.swift +++ /dev/null @@ -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() - } - }) - } -} diff --git a/Session/Conversations V2/Input View/VoiceMessageRecordingView.swift b/Session/Conversations V2/Input View/VoiceMessageRecordingView.swift new file mode 100644 index 000000000..91107aaa2 --- /dev/null +++ b/Session/Conversations V2/Input View/VoiceMessageRecordingView.swift @@ -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() + }) + } +} diff --git a/SignalUtilitiesKit/Utilities/OWSFormat.m b/SignalUtilitiesKit/Utilities/OWSFormat.m index effb40f4e..054d7860b 100644 --- a/SignalUtilitiesKit/Utilities/OWSFormat.m +++ b/SignalUtilitiesKit/Utilities/OWSFormat.m @@ -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]; } }