Signal-iOS/SignalUI/Views/TextAttachmentView.swift
Igor Solomennikov e926b26f5d
Text stories UI improvements
* Allow to force "compact" style for a link preview view.

Link previews should be displayed in "compact" mode when there's a significant
amount of text.

* RoundMediaButton should never be allowed to be made smaller than its desired size.

* Improved layout for text stories: both composer and viewer.

There is now a shared UI layout logic between editor and viewer, editor being
a subclass of viewer.

New behavior for composer:
 • text view will grow in height as much as possible and becomes scollable
   when max height is reached.
 • link preview starts in "regular" layout (image above the text) and then
   is updated to "compact" layout (image on the left, text on the right)
   as text view grows.

New behavior for viewer:
 • updated font sizes for match spec.
 • for extra tall stories text label is scaled down to fit.

* Fix bug where Proceed button would stay disabled after composing a text story.

* Fix two UX issues reported in PR review.
2022-10-05 12:46:03 -07:00

645 lines
25 KiB
Swift

//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
import UIKit
import SignalServiceKit
open class TextAttachmentView: UIView {
private var textAttachment: TextAttachment?
public let contentLayoutGuide = UILayoutGuide()
convenience public init(attachment: TextAttachment) {
self.init(
text: attachment.text,
textStyle: attachment.textStyle,
textForegroundColor: attachment.textForegroundColor,
textBackgroundColor: attachment.textBackgroundColor,
background: attachment.background,
linkPreview: attachment.preview
)
self.textAttachment = attachment
}
public init(
text: String?,
textStyle: TextAttachment.TextStyle,
textForegroundColor: UIColor?,
textBackgroundColor: UIColor?,
background: TextAttachment.Background,
linkPreview: OWSLinkPreview?
) {
self.text = text
self.textStyle = textStyle
self.textForegroundColor = textForegroundColor ?? Theme.darkThemePrimaryColor
self.textBackgroundColor = textBackgroundColor
self.background = background
super.init(frame: .zero)
addLayoutGuide(contentLayoutGuide)
let constraints = [
contentLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
contentLayoutGuide.topAnchor.constraint(equalTo: topAnchor, constant: 20),
contentLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -24),
contentLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20)
]
constraints.forEach { $0.priority = .defaultHigh }
addConstraints(constraints)
if let linkPreview = linkPreview {
var attachment: TSAttachment?
if let imageAttachmentId = linkPreview.imageAttachmentId {
attachment = databaseStorage.read(block: { TSAttachment.anyFetch(uniqueId: imageAttachmentId, transaction: $0) })
}
self.linkPreview = LinkPreviewSent(
linkPreview: linkPreview,
imageAttachment: attachment,
conversationStyle: nil
)
}
updateTextAttributes()
reloadLinkPreviewAppearance()
updateBackground()
}
public func asThumbnailView() -> TextAttachmentThumbnailView { TextAttachmentThumbnailView(self) }
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public enum LayoutConstants {
public static let textBackgroundHMargin: CGFloat = 16
public static let textBackgroundVMargin: CGFloat = 16
public static let textBackgroundCornerRadius: CGFloat = 18
public static let linkPreviewAreaTopMargin: CGFloat = 8
public static let linkPreviewHMargin: CGFloat = 12
public static let linkPreviewVMargin: CGFloat = 20
}
open var isEditing: Bool { false }
public private(set) var textContentSize: CGSize = .zero
public override func layoutSubviews() {
super.layoutSubviews()
// Resize link preview view to its desired size.
if let linkPreviewView = linkPreviewView {
let linkPreviewMaxSize = contentLayoutGuide.layoutFrame.inset(by: linkPreviewWrapperView.layoutMargins).size
let linkPreviewSize = linkPreviewView.systemLayoutSizeFitting(
linkPreviewMaxSize,
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
)
linkPreviewWrapperView.bounds.size = CGSize(
width: linkPreviewSize.width + 2 * LayoutConstants.linkPreviewHMargin,
height: linkPreviewSize.height + 2 * LayoutConstants.linkPreviewVMargin
)
linkPreviewView.frame = linkPreviewWrapperView.bounds.insetBy(
dx: LayoutConstants.linkPreviewHMargin,
dy: LayoutConstants.linkPreviewVMargin
)
}
textContentSize = calculateTextContentSize()
// If link preview view has "regular" (tall) layout and there's no enough vertical space for both link and text,
// we force "compact" layout for the link preview and trigger a new layout pass.
if let linkPreviewView = linkPreviewView, linkPreviewView.layout == .regular, textContentSize.height > 0 {
let contentHeight = textContentSize.height + LayoutConstants.linkPreviewAreaTopMargin + linkPreviewWrapperView.frame.height
if contentHeight > contentLayoutGuide.layoutFrame.height {
forceCompactLayoutForLinkPreview = true
reloadLinkPreviewAppearance()
return
}
}
layoutTextContentAndLinkPreview()
}
open func layoutTextContentAndLinkPreview() {
var maxTextAreaHeight = contentLayoutGuide.layoutFrame.height
var linkPreviewAreaHeight: CGFloat = 0
if linkPreviewView != nil {
linkPreviewAreaHeight = linkPreviewWrapperView.frame.height
maxTextAreaHeight -= (linkPreviewAreaHeight + LayoutConstants.linkPreviewAreaTopMargin)
}
var textAreaHeight: CGFloat = 0
// Position text and/or link preview.
if let textLabel = textLabel, textContentSize.height > 0 {
textLabel.bounds.size = textContentSize
let cappedTextContentHeight = min(textContentSize.height, maxTextAreaHeight - 2 * LayoutConstants.textBackgroundVMargin)
let scaleFactor = min(1, cappedTextContentHeight / textContentSize.height)
textLabel.transform = CGAffineTransform.scale(scaleFactor)
let verticalOffset = linkPreviewAreaHeight > 0 ? 0.5 * (linkPreviewAreaHeight + LayoutConstants.linkPreviewAreaTopMargin) : 0
textLabel.center = CGPoint(
x: contentLayoutGuide.layoutFrame.center.x,
y: contentLayoutGuide.layoutFrame.center.y - verticalOffset
)
if let textBackgroundView = textBackgroundView {
textBackgroundView.frame = convert(textLabel.bounds, from: textLabel).insetBy(
dx: -LayoutConstants.textBackgroundHMargin,
dy: -LayoutConstants.textBackgroundVMargin
)
}
textAreaHeight = cappedTextContentHeight + 2 * LayoutConstants.textBackgroundVMargin
}
if linkPreviewView != nil {
let verticalOffset = textAreaHeight > 0 ? 0.5 * (textAreaHeight + LayoutConstants.linkPreviewAreaTopMargin) : 0
linkPreviewWrapperView.center = CGPoint(
x: contentLayoutGuide.layoutFrame.center.x,
y: contentLayoutGuide.layoutFrame.center.y + verticalOffset
)
}
}
open func calculateTextContentSize() -> CGSize {
guard let textLabel = textLabel else {
return .zero
}
let maxTextLabelSize = contentLayoutGuide.layoutFrame.insetBy(
dx: LayoutConstants.textBackgroundHMargin,
dy: LayoutConstants.textBackgroundVMargin
).size
return textLabel.systemLayoutSizeFitting(
maxTextLabelSize,
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
)
}
// MARK: - Attributes
public var text: String? {
didSet { updateTextAttributes() }
}
public var textStyle: TextAttachment.TextStyle = .regular {
didSet { updateTextAttributes() }
}
public private(set) var textForegroundColor: UIColor = Theme.darkThemePrimaryColor
public private(set) var textBackgroundColor: UIColor?
public func setTextForegroundColor(_ textForegroundColor: UIColor, backgroundColor: UIColor?) {
self.textForegroundColor = textForegroundColor
self.textBackgroundColor = backgroundColor
updateTextAttributes()
}
// MARK: - Text
public func sizeAndAlignment(forText text: String) -> (fontPointSize: CGFloat, textAlignment: NSTextAlignment) {
switch text.count {
case ..<50: return (34, .center)
case 50...199: return (24, .center)
default: return (18, .natural)
}
}
public func updateTextAttributes() {
defer { updateVisibilityOfComponents(animated: false) }
guard let text = text else { return }
var textLabel: UILabel
if let existingLabel = self.textLabel {
textLabel = existingLabel
} else {
textLabel = UILabel()
textLabel.adjustsFontSizeToFitWidth = true
textLabel.allowsDefaultTighteningForTruncation = true
textLabel.lineBreakMode = .byWordWrapping
textLabel.minimumScaleFactor = 0.2
textLabel.numberOfLines = 0
addSubview(textLabel)
self.textLabel = textLabel
}
let (fontPointSize, textAlignment) = sizeAndAlignment(forText: text)
textLabel.text = transformedText(text, for: textStyle)
textLabel.textAlignment = textAlignment
textLabel.font = font(for: textStyle, withPointSize: fontPointSize)
textLabel.textColor = textForegroundColor
if let textBackgroundColor = textBackgroundColor {
var textBackgroundView: UIView
if let existingBackgroundView = self.textBackgroundView {
textBackgroundView = existingBackgroundView
} else {
textBackgroundView = UIView()
textBackgroundView.layer.cornerRadius = LayoutConstants.textBackgroundCornerRadius
insertSubview(textBackgroundView, belowSubview: textLabel)
self.textBackgroundView = textBackgroundView
}
textBackgroundView.backgroundColor = textBackgroundColor
}
setNeedsLayout()
}
private func transformedText(_ text: String, for textStyle: TextAttachment.TextStyle) -> String {
guard case .condensed = textStyle else { return text }
return text.uppercased()
}
public func font(for textStyle: TextAttachment.TextStyle, withPointSize pointSize: CGFloat) -> UIFont {
let attributes: [UIFontDescriptor.AttributeName: Any]
switch textStyle {
case .regular:
attributes = [.name: "Inter-Regular_Medium"]
case .bold:
attributes = [.name: "Inter-Regular_Black"]
case .serif:
attributes = [.name: "EBGaramond-Regular"]
case .script:
attributes = [.name: "Parisienne-Regular"]
case .condensed:
// TODO: Ideally we could set an attribute to make this font
// all caps, but iOS deprecated that ability and didn't add
// a new equivalent function.
attributes = [.name: "BarlowCondensed-Medium"]
}
// TODO: Eventually we'll want to provide a cascadeList here to fallback
// to different fonts for different scripts rather than just relying on
// the built in OS fallbacks that don't tend to match the desired style.
let descriptor = UIFontDescriptor(fontAttributes: attributes)
return UIFont(descriptor: descriptor, size: pointSize)
}
open func updateVisibilityOfComponents(animated: Bool) {
let isEditing = isEditing
if text != nil {
textLabel?.setIsHidden(isEditing, animated: animated)
textBackgroundView?.setIsHidden(isEditing || textBackgroundColor == nil, animated: animated)
} else {
textLabel?.setIsHidden(true, animated: animated)
textBackgroundView?.setIsHidden(true, animated: animated)
}
}
public private(set) var textLabel: UILabel?
public private(set) var textBackgroundView: UIView?
// MARK: - Background
public var background: TextAttachment.Background {
didSet { updateBackground() }
}
private var gradientView: GradientView?
private func updateBackground() {
switch background {
case .color(let color):
if let gradientView = gradientView {
gradientView.isHidden = true
}
backgroundColor = color
case .gradient(let gradient):
var gradientView: GradientView
if let existingGradientView = self.gradientView {
gradientView = existingGradientView
} else {
gradientView = GradientView(colors: [])
insertSubview(gradientView, at: 0)
gradientView.autoPinEdgesToSuperviewEdges()
self.gradientView = gradientView
}
gradientView.isHidden = false
gradientView.colors = gradient.colors
gradientView.locations = gradient.locations
gradientView.setAngle(gradient.angle)
}
}
// MARK: - Link Preview
public var linkPreview: LinkPreviewState? {
didSet { reloadLinkPreviewAppearance() }
}
public private(set) var linkPreviewView: LinkPreviewView?
public private(set) lazy var linkPreviewWrapperView = UIView()
private var forceCompactLayoutForLinkPreview = false
private func shouldUseCompactLayoutForLinkPreview() -> Bool {
if let text = text, text.count >= 50 { return true }
return forceCompactLayoutForLinkPreview
}
open func reloadLinkPreviewAppearance() {
if let linkPreviewView = linkPreviewView {
linkPreviewView.removeFromSuperview()
self.linkPreviewView = nil
}
defer {
setNeedsLayout()
}
guard let linkPreview = linkPreview else {
linkPreviewWrapperView.isHidden = true
return
}
if linkPreviewWrapperView.superview == nil {
addSubview(linkPreviewWrapperView)
}
linkPreviewWrapperView.isHidden = false
let linkPreviewView = TextAttachmentView.LinkPreviewView(
linkPreview: linkPreview,
forceCompactSize: shouldUseCompactLayoutForLinkPreview()
)
linkPreviewWrapperView.addSubview(linkPreviewView)
self.linkPreviewView = linkPreviewView
}
public var isPresentingLinkTooltip: Bool { linkPreviewTooltipView != nil }
private var linkPreviewTooltipView: LinkPreviewTooltipView?
public func willHandleTapGesture(_ gesture: UITapGestureRecognizer) -> Bool {
if let linkPreviewTooltipView = linkPreviewTooltipView {
if let container = linkPreviewTooltipView.superview,
linkPreviewTooltipView.frame.contains(gesture.location(in: container)) {
CurrentAppContext().open(linkPreviewTooltipView.url)
} else {
linkPreviewTooltipView.removeFromSuperview()
self.linkPreviewTooltipView = nil
}
return true
} else if let linkPreviewView = linkPreviewView,
let urlString = textAttachment?.preview?.urlString,
let container = linkPreviewView.superview,
linkPreviewView.frame.contains(gesture.location(in: container)) {
let tooltipView = LinkPreviewTooltipView(
fromView: self,
tailReferenceView: linkPreviewView,
url: URL(string: urlString)!
)
self.linkPreviewTooltipView = tooltipView
return true
}
return false
}
// MARK: - LinkPreviewView
public class LinkPreviewView: UIStackView {
public enum Layout {
case regular
case compact
case draft
case domainOnly
}
public private(set) var layout: Layout = .regular
public init(linkPreview: LinkPreviewState, isDraft: Bool = false, forceCompactSize: Bool = false) {
super.init(frame: .zero)
let backgroundColor: UIColor = isDraft ? Theme.darkThemeTableView2PresentedBackgroundColor : .ows_gray02
let backgroundView = addBackgroundView(withBackgroundColor: backgroundColor)
let title = linkPreview.title()
let description = linkPreview.previewDescription()
let hasTitleOrDescription = title != nil || description != nil
if isDraft {
layout = .draft
} else if hasTitleOrDescription {
layout = forceCompactSize ? .compact : .regular
} else {
layout = .domainOnly
}
let thumbnailImageView = UIImageView()
thumbnailImageView.clipsToBounds = true
if layout != .domainOnly && linkPreview.imageState() == .loaded {
thumbnailImageView.contentMode = .scaleAspectFill
// Downgrade "regular" to "compact" if thumbnail is too small.
let imageSize = linkPreview.imagePixelSize
if layout == .regular && (imageSize.width < 300 || imageSize.height < 300) {
layout = .compact
}
linkPreview.imageAsync(thumbnailQuality: layout == .regular ? .mediumLarge : .small) { image in
thumbnailImageView.image = image
}
} else {
// Dark placeholder icon on light background if there's no thumbnail associated with the link preview.
layout = .compact
thumbnailImageView.backgroundColor = .ows_gray02
thumbnailImageView.contentMode = .center
thumbnailImageView.image = UIImage(imageLiteralResourceName: "link-diagonal")
thumbnailImageView.tintColor = Theme.lightThemePrimaryColor
}
alignment = .fill
axis = layout == .regular ? .vertical : .horizontal
switch layout {
case .regular:
backgroundView.layer.cornerRadius = 18
thumbnailImageView.autoSetDimension(.height, toSize: 152)
thumbnailImageView.layer.maskedCorners = [ .layerMinXMinYCorner, .layerMaxXMinYCorner ]
case .compact:
backgroundView.layer.cornerRadius = 18
thumbnailImageView.autoSetDimension(.width, toSize: 88)
// Allow thumbnail to grow vertically with the text.
thumbnailImageView.autoSetDimension(.height, toSize: 88, relation: .greaterThanOrEqual)
thumbnailImageView.layer.maskedCorners = [ .layerMinXMinYCorner, .layerMinXMaxYCorner ]
case .draft:
backgroundView.layer.cornerRadius = 8
thumbnailImageView.autoSetDimension(.width, toSize: 76)
// Allow thumbnail to grow vertically with the text.
thumbnailImageView.autoSetDimension(.height, toSize: 76, relation: .greaterThanOrEqual)
thumbnailImageView.layer.maskedCorners = .all
case .domainOnly:
backgroundView.layer.cornerRadius = 12
thumbnailImageView.autoSetDimensions(to: CGSize(width: 50, height: 50))
}
thumbnailImageView.layer.cornerRadius = backgroundView.layer.cornerRadius
addArrangedSubview(thumbnailImageView)
let previewVStack = UIStackView()
previewVStack.axis = .vertical
previewVStack.spacing = 2
previewVStack.alignment = .leading
previewVStack.isLayoutMarginsRelativeArrangement = true
previewVStack.layoutMargins = UIEdgeInsets(hMargin: 12, vMargin: 8)
// Make placeholder icon look centered between leading edge of the panel and text.
if layout == .domainOnly {
previewVStack.layoutMargins.leading = 0
}
addArrangedSubview(previewVStack)
if let title = title {
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = .ows_dynamicTypeSubheadlineClamped.ows_semibold
titleLabel.textColor = isDraft ? Theme.darkThemePrimaryColor : Theme.lightThemePrimaryColor
titleLabel.numberOfLines = 2
titleLabel.setCompressionResistanceVerticalHigh()
titleLabel.setContentHuggingVerticalHigh()
previewVStack.addArrangedSubview(titleLabel)
}
if let description = description {
let descriptionLabel = UILabel()
descriptionLabel.text = description
descriptionLabel.font = .ows_dynamicTypeFootnoteClamped
descriptionLabel.textColor = isDraft ? Theme.darkThemePrimaryColor : Theme.lightThemePrimaryColor
descriptionLabel.numberOfLines = 2
descriptionLabel.setCompressionResistanceVerticalHigh()
descriptionLabel.setContentHuggingVerticalHigh()
previewVStack.addArrangedSubview(descriptionLabel)
}
let footerLabel = UILabel()
footerLabel.numberOfLines = 1
if hasTitleOrDescription {
footerLabel.font = .ows_dynamicTypeCaption1Clamped
footerLabel.textColor = isDraft ? Theme.darkThemeSecondaryTextAndIconColor : .ows_gray60
} else {
footerLabel.font = .ows_dynamicTypeSubheadlineClamped.ows_semibold
footerLabel.textColor = isDraft ? Theme.darkThemePrimaryColor : Theme.lightThemePrimaryColor
}
footerLabel.setCompressionResistanceVerticalHigh()
footerLabel.setContentHuggingVerticalHigh()
previewVStack.addArrangedSubview(footerLabel)
var footerText: String
if let displayDomain = OWSLinkPreviewManager.displayDomain(forUrl: linkPreview.urlString()) {
footerText = displayDomain.lowercased()
} else {
footerText = NSLocalizedString(
"LINK_PREVIEW_UNKNOWN_DOMAIN",
comment: "Label for link previews with an unknown host."
).uppercased()
}
if let date = linkPreview.date() {
footerText.append("\(Self.dateFormatter.string(from: date))")
}
footerLabel.text = footerText
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter
}()
}
}
private class LinkPreviewTooltipView: TooltipView {
let url: URL
init(fromView: UIView, tailReferenceView: UIView, url: URL) {
self.url = url
super.init(
fromView: fromView,
widthReferenceView: fromView,
tailReferenceView: tailReferenceView,
wasTappedBlock: nil
)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func bubbleContentView() -> UIView {
let titleLabel = UILabel()
titleLabel.text = NSLocalizedString(
"STORY_LINK_PREVIEW_VISIT_LINK_TOOLTIP",
comment: "Tooltip prompting the user to visit a story link."
)
titleLabel.font = UIFont.ows_dynamicTypeBody2Clamped.ows_semibold
titleLabel.textColor = .ows_white
let urlLabel = UILabel()
urlLabel.text = url.absoluteString
urlLabel.font = .ows_dynamicTypeCaption1Clamped
urlLabel.textColor = .ows_white
let stackView = UIStackView(arrangedSubviews: [titleLabel, urlLabel])
stackView.axis = .vertical
stackView.alignment = .leading
stackView.spacing = 1
stackView.layoutMargins = UIEdgeInsets(hMargin: 16, vMargin: 14)
stackView.isLayoutMarginsRelativeArrangement = true
return stackView
}
public override var bubbleColor: UIColor { .ows_black }
public override var bubbleHSpacing: CGFloat { 16 }
public override var tailDirection: TooltipView.TailDirection { .down }
public override var dismissOnTap: Bool { false }
}
public class TextAttachmentThumbnailView: UIView {
// By default, we render the textView at a large 3:2 size (matching the aspect
// of the thumbnail container), so the fonts and gradients all render properly
// for the preview. We then scale it down to render a "thumbnail" view.
public static let defaultRenderSize = CGSize(width: 375, height: 563)
public lazy var renderSize = Self.defaultRenderSize {
didSet {
textAttachmentView.transform = .scale(width / renderSize.width)
}
}
private let textAttachmentView: TextAttachmentView
public init(_ textAttachmentView: TextAttachmentView) {
self.textAttachmentView = textAttachmentView
super.init(frame: .zero)
addSubview(textAttachmentView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func layoutSubviews() {
super.layoutSubviews()
textAttachmentView.transform = .scale(width / renderSize.width)
textAttachmentView.frame = bounds
}
}