TM-SGNL-iOS/SignalUI/AttachmentApproval/AttachmentApprovalToolbar.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

450 lines
17 KiB
Swift

//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import SignalServiceKit
import UIKit
class AttachmentApprovalToolbar: UIView {
struct Configuration: Equatable {
var isAddMoreVisible = true
var isMediaStripVisible = false
var isMediaHighQualityEnabled = false
var isViewOnceOn = false
var canToggleViewOnce = true
var canChangeMediaQuality = true
var canSaveMedia = false
var doneButtonIcon: DoneButtonIcon = .send
enum DoneButtonIcon: String {
case send = "send-blue-42-dark"
case next = "chevron-right-colored-42"
}
}
var configuration: Configuration
// Only visible when there's one media item and contains "Add Media" and "View Once" buttons.
// Displayed in place of galleryRailView.
private lazy var singleMediaActionButtonsContainer: UIView = {
let view = UIView()
view.preservesSuperviewLayoutMargins = true
view.layoutMargins.bottom = 0
view.addSubview(buttonAddMedia)
buttonAddMedia.autoPinHeightToSuperviewMargins()
buttonAddMedia.layoutMarginsGuide.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor).isActive = true
view.addSubview(buttonViewOnce)
buttonViewOnce.autoPinHeightToSuperviewMargins()
buttonViewOnce.layoutMarginsGuide.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor).isActive = true
return view
}()
let buttonAddMedia: UIButton = RoundMediaButton(
image: UIImage(imageLiteralResourceName: "plus-square-28"),
backgroundStyle: .blur
)
let buttonViewOnce: UIButton = RoundMediaButton(
image: UIImage(imageLiteralResourceName: "view_once-28"),
backgroundStyle: .blur
)
// Contains message input field and a button to finish editing.
let attachmentTextToolbar: AttachmentTextToolbar
weak var attachmentTextToolbarDelegate: AttachmentTextToolbarDelegate?
// Shows previews of media object.
let galleryRailView: GalleryRailView
// Row of buttons at the bottom of the screen.
private let mediaToolbar = MediaToolbar()
private lazy var opaqueContentView: UIStackView = {
let stackView = UIStackView(arrangedSubviews: [ attachmentTextToolbar, mediaToolbar ])
stackView.axis = .vertical
stackView.preservesSuperviewLayoutMargins = true
return stackView
}()
private lazy var containerStackView: UIStackView = {
let stackView = UIStackView(arrangedSubviews: [ opaqueContentView ])
stackView.axis = .vertical
stackView.preservesSuperviewLayoutMargins = true
return stackView
}()
private var viewOnceTooltip: UIView?
var isEditingMediaMessage: Bool {
return attachmentTextToolbar.isEditingText
}
private var currentAttachmentItem: AttachmentApprovalItem?
override init(frame: CGRect) {
configuration = Configuration()
attachmentTextToolbar = AttachmentTextToolbar()
attachmentTextToolbar.setIsViewOnce(enabled: configuration.isViewOnceOn, animated: false)
galleryRailView = GalleryRailView()
galleryRailView.itemSize = 44
galleryRailView.scrollFocusMode = .keepWithinBounds
super.init(frame: frame)
createContents()
}
@available(*, unavailable, message: "Use init(frame:) instead")
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func createContents() {
backgroundColor = .clear
layoutMargins.bottom = 0
preservesSuperviewLayoutMargins = true
attachmentTextToolbar.delegate = self
addSubview(galleryRailView)
galleryRailView.autoPinWidthToSuperview()
galleryRailView.autoPinEdge(toSuperviewEdge: .top)
addSubview(singleMediaActionButtonsContainer)
singleMediaActionButtonsContainer.autoPinWidthToSuperview()
singleMediaActionButtonsContainer.autoPinEdge(.bottom, to: .bottom, of: galleryRailView)
// Use a background view that extends below the keyboard to avoid animation glitches.
let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
addSubview(backgroundView)
backgroundView.autoPinWidthToSuperview()
backgroundView.autoPinEdge(.top, to: .bottom, of: galleryRailView)
backgroundView.autoPinEdge(toSuperviewEdge: .bottom, withInset: -30)
addSubview(containerStackView)
containerStackView.autoPinEdge(.top, to: .bottom, of: galleryRailView)
containerStackView.autoPinWidthToSuperview()
// We pin to the superview's _margin_. Otherwise the notch breaks
// the layout if you hide the keyboard in the simulator (or if the
// user uses an external keyboard).
containerStackView.autoPinEdge(toSuperviewMargin: .bottom)
}
private var supplementaryViewContainer: UIView?
func set(supplementaryView: UIView?) {
if let supplementaryViewContainer = supplementaryViewContainer {
supplementaryViewContainer.removeFromSuperview()
containerStackView.removeArrangedSubview(supplementaryViewContainer)
self.supplementaryViewContainer = nil
}
guard let supplementaryView = supplementaryView else {
return
}
let containerView = UIView()
containerView.preservesSuperviewLayoutMargins = true
containerView.addSubview(supplementaryView)
supplementaryView.autoPinEdgesToSuperviewMargins()
containerStackView.insertArrangedSubview(containerView, at: 0)
self.supplementaryViewContainer = containerView
}
var opaqueAreaHeight: CGFloat { opaqueContentView.height }
private func updateContents(animated: Bool) {
// Show/hide Gallery Rail.
let isGalleryRailViewVisible = configuration.isMediaStripVisible && !isEditingMediaMessage
galleryRailView.setIsHidden(!isGalleryRailViewVisible, animated: animated)
// Show/hide [+] Add Media button and "View Once" toggle.
let isSingleMediaActionsVisible = !configuration.isMediaStripVisible && !isEditingMediaMessage
singleMediaActionButtonsContainer.setIsHidden(!isSingleMediaActionsVisible, animated: animated)
// [+] Add Media might also be hidden independently of Media Rail and View Once.
buttonAddMedia.setIsHidden(!configuration.isAddMoreVisible, animated: animated)
// Update image and visibility of the "View Once" button.
let viewOnceButtonImage = UIImage(imageLiteralResourceName: configuration.isViewOnceOn ? "view_once-28" : "view_once-infinite-28")
buttonViewOnce.setImage(viewOnceButtonImage, animated: animated)
buttonViewOnce.setIsHidden(!configuration.canToggleViewOnce, animated: animated)
supplementaryViewContainer?.isHiddenInStackView = isEditingMediaMessage
attachmentTextToolbar.setIsViewOnce(enabled: configuration.isViewOnceOn, animated: animated)
// Visibility of bottom buttons only changes when user starts/finishes composing text message.
// In that case `updateContents(animated:)` is called from within an animation block
// and since `mediaToolbar` is in a stack view it is necessary to modify `isHiddenInStackView`
// to get a nice animation.
mediaToolbar.isHiddenInStackView = isEditingMediaMessage
mediaToolbar.sendButton.setImage(UIImage(imageLiteralResourceName: configuration.doneButtonIcon.rawValue), for: .normal)
mediaToolbar.setIsMediaQualityHigh(enabled: configuration.isMediaHighQualityEnabled, animated: animated)
let availableButtons: MediaToolbar.AvailableButtons = {
guard let currentAttachmentItem = currentAttachmentItem else {
return []
}
var buttons: MediaToolbar.AvailableButtons = []
if configuration.canSaveMedia {
buttons.insert(.save)
}
if configuration.canChangeMediaQuality {
buttons.insert(.mediaQuality)
}
switch currentAttachmentItem.type {
case .image:
buttons.insert(.pen)
buttons.insert(.crop)
default:
break
}
return buttons
}()
mediaToolbar.set(availableButtons: availableButtons, animated: animated)
updateFirstResponder()
showViewOnceTooltipIfNecessary()
}
override func resignFirstResponder() -> Bool {
if isEditingMediaMessage {
return attachmentTextToolbar.textView.resignFirstResponder()
} else {
return super.resignFirstResponder()
}
}
private func updateFirstResponder() {
if configuration.isViewOnceOn {
if isEditingMediaMessage {
_ = attachmentTextToolbar.textView.resignFirstResponder()
}
}
// NOTE: We don't automatically make attachmentTextToolbar.textView
// first responder;
}
func update(currentAttachmentItem: AttachmentApprovalItem,
configuration: Configuration,
animated: Bool) {
// De-bounce
guard self.currentAttachmentItem != currentAttachmentItem || self.configuration != configuration else {
updateFirstResponder()
return
}
self.currentAttachmentItem = currentAttachmentItem
self.configuration = configuration
updateContents(animated: animated)
}
// Since we have `self.autoresizingMask = UIViewAutoresizingFlexibleHeight`, we must specify
// an intrinsicContentSize. Specifying CGSize.zero causes the height to be determined by autolayout.
override var intrinsicContentSize: CGSize { .zero }
public var hasFirstResponder: Bool {
return (isFirstResponder || attachmentTextToolbar.textView.isFirstResponder)
}
}
extension AttachmentApprovalToolbar: AttachmentTextToolbarDelegate {
func attachmentTextToolbarWillBeginEditing(_ attachmentTextToolbar: AttachmentTextToolbar) {
attachmentTextToolbarDelegate?.attachmentTextToolbarWillBeginEditing(attachmentTextToolbar)
}
func attachmentTextToolbarDidBeginEditing(_ attachmentTextToolbar: AttachmentTextToolbar) {
updateContents(animated: true)
attachmentTextToolbarDelegate?.attachmentTextToolbarDidBeginEditing(attachmentTextToolbar)
}
func attachmentTextToolbarDidEndEditing(_ attachmentTextToolbar: AttachmentTextToolbar) {
updateContents(animated: true)
attachmentTextToolbarDelegate?.attachmentTextToolbarDidEndEditing(attachmentTextToolbar)
}
func attachmentTextToolbarDidChange(_ attachmentTextToolbar: AttachmentTextToolbar) {
attachmentTextToolbarDelegate?.attachmentTextToolbarDidChange(attachmentTextToolbar)
}
func attachmentTextToolBarDidChangeHeight(_ attachmentTextToolbar: AttachmentTextToolbar) {
setNeedsLayout()
layoutIfNeeded()
}
}
// MARK: - View Once Tooltip
extension AttachmentApprovalToolbar {
// The tooltip lies outside this view's bounds, so we
// need to special-case the hit testing so that it can
// intercept touches within its bounds.
public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
if let viewOnceTooltip = self.viewOnceTooltip {
let tooltipFrame = convert(viewOnceTooltip.bounds, from: viewOnceTooltip)
if tooltipFrame.contains(point) {
return true
}
}
return super.point(inside: point, with: event)
}
private var shouldShowViewOnceTooltip: Bool {
guard !configuration.isMediaStripVisible else {
return false
}
guard !configuration.isViewOnceOn && configuration.canToggleViewOnce else {
return false
}
guard !SSKEnvironment.shared.preferencesRef.wasViewOnceTooltipShown else {
return false
}
return true
}
// Show the tooltip if a) it should be shown b) isn't already showing.
private func showViewOnceTooltipIfNecessary() {
guard shouldShowViewOnceTooltip else {
return
}
guard nil == viewOnceTooltip else {
// Already showing the tooltip.
return
}
let tooltip = ViewOnceTooltip.present(fromView: self, widthReferenceView: self, tailReferenceView: buttonViewOnce) { [weak self] in
self?.removeViewOnceTooltip()
}
viewOnceTooltip = tooltip
DispatchQueue.global().async {
SSKEnvironment.shared.preferencesRef.setWasViewOnceTooltipShown()
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 5) { [weak self] in
self?.removeViewOnceTooltip()
}
}
}
private func removeViewOnceTooltip() {
viewOnceTooltip?.removeFromSuperview()
viewOnceTooltip = nil
}
}
// MARK: - Bottom Row Buttons
extension AttachmentApprovalToolbar {
var buttonSend: UIButton {
mediaToolbar.sendButton
}
var buttonMediaQuality: UIButton {
mediaToolbar.mediaQualityButton
}
var buttonSaveMedia: UIButton {
mediaToolbar.saveMediaButton
}
var buttonPenTool: UIButton {
mediaToolbar.penToolButton
}
var buttonCropTool: UIButton {
mediaToolbar.cropToolButton
}
}
private class MediaToolbar: UIView {
struct AvailableButtons: OptionSet {
let rawValue: Int
static let pen = AvailableButtons(rawValue: 1 << 0)
static let crop = AvailableButtons(rawValue: 1 << 1)
static let save = AvailableButtons(rawValue: 1 << 2)
static let mediaQuality = AvailableButtons(rawValue: 1 << 3)
static let all: AvailableButtons = [ .pen, .crop, .save, .mediaQuality ]
}
func set(availableButtons: AvailableButtons, animated: Bool) {
penToolButton.setIsHidden(!availableButtons.contains(.pen), animated: animated)
cropToolButton.setIsHidden(!availableButtons.contains(.crop), animated: animated)
saveMediaButton.setIsHidden(!availableButtons.contains(.save), animated: animated)
mediaQualityButton.setIsHidden(!availableButtons.contains(.mediaQuality), animated: animated)
}
override init(frame: CGRect) {
super.init(frame: frame)
preservesSuperviewLayoutMargins = true
let stackView = UIStackView(arrangedSubviews: [ penToolButton, cropToolButton, mediaQualityButton,
saveMediaButton, UIView.transparentSpacer(), sendButton ])
stackView.spacing = 4
addSubview(stackView)
stackView.autoPinLeadingToSuperviewMargin(withInset: -penToolButton.layoutMargins.leading)
sendButton.layoutMarginsGuide.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor).isActive = true
stackView.autoPinEdge(toSuperviewEdge: .top)
stackView.autoPinEdge(toSuperviewEdge: .bottom, withInset: UIDevice.current.hasIPhoneXNotch ? 0 : 8)
stackView.arrangedSubviews.compactMap { $0 as? UIButton }.forEach { button in
button.setCompressionResistanceHigh()
}
}
@available(*, unavailable, message: "Use init(frame:) instead")
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Layout
static private let buttonBackgroundColor = RoundMediaButton.defaultBackgroundColor
let penToolButton: UIButton = RoundMediaButton(
image: UIImage(imageLiteralResourceName: "brush-pen-28"),
backgroundStyle: .solid(buttonBackgroundColor)
)
let cropToolButton: UIButton = RoundMediaButton(
image: UIImage(imageLiteralResourceName: "crop-rotate-28"),
backgroundStyle: .solid(buttonBackgroundColor)
)
lazy var mediaQualityButton: UIButton = RoundMediaButton(
image: MediaToolbar.imageMediaQualityStandard,
backgroundStyle: .solid(MediaToolbar.buttonBackgroundColor)
)
let saveMediaButton: UIButton = RoundMediaButton(
image: UIImage(imageLiteralResourceName: "save-28"),
backgroundStyle: .solid(buttonBackgroundColor)
)
let sendButton: UIButton = {
let button = UIButton(type: .system)
button.setImage(
UIImage(imageLiteralResourceName: AttachmentApprovalToolbar.Configuration.DoneButtonIcon.send.rawValue),
for: .normal
)
button.ows_contentEdgeInsets = UIEdgeInsets(margin: UIDevice.current.isNarrowerThanIPhone6 ? 4 : 8)
button.accessibilityLabel = MessageStrings.sendButton
button.sizeToFit()
return button
}()
private static let imageMediaQualityHigh = UIImage(imageLiteralResourceName: "quality-high")
private static let imageMediaQualityStandard = UIImage(imageLiteralResourceName: "quality-standard")
fileprivate func setIsMediaQualityHigh(enabled: Bool, animated: Bool) {
let image = enabled ? MediaToolbar.imageMediaQualityHigh : MediaToolbar.imageMediaQualityStandard
mediaQualityButton.setImage(image, animated: animated)
}
}