* 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.
283 lines
11 KiB
Swift
283 lines
11 KiB
Swift
//
|
|
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
public class MediaTextView: UITextView {
|
|
|
|
public enum DecorationStyle: Int {
|
|
case none = 0
|
|
case inverted
|
|
case underline
|
|
case outline
|
|
}
|
|
|
|
public enum TextStyle: Int {
|
|
case regular = 0
|
|
case bold
|
|
case serif
|
|
case script
|
|
case condensed
|
|
}
|
|
|
|
class func font(forTextStyle textStyle: TextStyle, pointSize: CGFloat) -> UIFont {
|
|
// TODO: this is a copy-paste code from TextAttachmentView that needs to be consolidated in one place.
|
|
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)
|
|
}
|
|
|
|
private var kvoObservation: NSKeyValueObservation?
|
|
|
|
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
|
super.init(frame: frame, textContainer: textContainer)
|
|
|
|
backgroundColor = .clear
|
|
isOpaque = false
|
|
isScrollEnabled = false
|
|
keyboardAppearance = .dark
|
|
scrollsToTop = false
|
|
textAlignment = .center
|
|
tintColor = .white
|
|
self.textContainer.lineFragmentPadding = 0
|
|
|
|
kvoObservation = observe(\.contentSize, options: [.new]) { [weak self] _, _ in
|
|
guard let self = self else { return }
|
|
self.adjustFontSizeIfNecessary()
|
|
}
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
private func adjustFontSizeIfNecessary() {
|
|
// TODO: Figure out correct way to handle long text and implement it.
|
|
}
|
|
|
|
public func update(using textStylingToolbar: TextStylingToolbar,
|
|
fontPointSize: CGFloat,
|
|
textAlignment: NSTextAlignment = .center) {
|
|
let font = MediaTextView.font(forTextStyle: textStylingToolbar.textStyle, pointSize: fontPointSize)
|
|
updateWith(textForegroundColor: textStylingToolbar.textForegroundColor,
|
|
font: font,
|
|
textAlignment: textAlignment,
|
|
textDecorationColor: textStylingToolbar.textDecorationColor,
|
|
decorationStyle: textStylingToolbar.decorationStyle)
|
|
}
|
|
|
|
public func updateWith(textForegroundColor: UIColor,
|
|
font: UIFont,
|
|
textAlignment: NSTextAlignment,
|
|
textDecorationColor: UIColor?,
|
|
decorationStyle: MediaTextView.DecorationStyle) {
|
|
var attributes: [NSAttributedString.Key: Any] = [ .font: font]
|
|
|
|
attributes[.foregroundColor] = textForegroundColor
|
|
|
|
if let paragraphStyle = NSParagraphStyle.default.mutableCopy() as? NSMutableParagraphStyle {
|
|
paragraphStyle.alignment = textAlignment
|
|
attributes[.paragraphStyle] = paragraphStyle
|
|
}
|
|
|
|
if let textDecorationColor = textDecorationColor {
|
|
switch decorationStyle {
|
|
case .underline:
|
|
attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
|
|
attributes[.underlineColor] = textDecorationColor
|
|
|
|
case .outline:
|
|
attributes[.strokeWidth] = -3
|
|
attributes[.strokeColor] = textDecorationColor
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
attributedText = NSAttributedString(string: text, attributes: attributes)
|
|
|
|
// This makes UITextView apply text styling to the text that user enters.
|
|
typingAttributes = attributes
|
|
|
|
invalidateIntrinsicContentSize()
|
|
}
|
|
|
|
// MARK: - Key Commands
|
|
|
|
public override var keyCommands: [UIKeyCommand]? {
|
|
return [
|
|
UIKeyCommand(input: "\r", modifierFlags: .command, action: #selector(modifiedReturnPressed(sender:)), discoverabilityTitle: "Add Text"),
|
|
UIKeyCommand(input: "\r", modifierFlags: .alternate, action: #selector(modifiedReturnPressed(sender:)), discoverabilityTitle: "Add Text")
|
|
]
|
|
}
|
|
|
|
@objc
|
|
private func modifiedReturnPressed(sender: UIKeyCommand) {
|
|
Logger.verbose("")
|
|
|
|
acceptAutocorrectSuggestion()
|
|
resignFirstResponder()
|
|
}
|
|
}
|
|
|
|
public class TextStylingToolbar: UIControl {
|
|
|
|
public enum Layout {
|
|
case photoOverlay
|
|
case textStory
|
|
}
|
|
let layout: Layout
|
|
|
|
private let colorPickerView: ColorPickerBarView
|
|
|
|
// Photo Editor operates with ColorPickerBarColor hence the need to expose this value.
|
|
public var currentColorPickerValue: ColorPickerBarColor {
|
|
get { colorPickerView.selectedValue }
|
|
set { colorPickerView.selectedValue = newValue }
|
|
}
|
|
|
|
public static func defaultColor(forLayout layout: Layout) -> ColorPickerBarColor {
|
|
switch layout {
|
|
case .photoOverlay:
|
|
return ColorPickerBarColor.defaultColor()
|
|
case .textStory:
|
|
return ColorPickerBarColor.white
|
|
}
|
|
}
|
|
|
|
public let textStyleButton = RoundMediaButton(image: #imageLiteral(resourceName: "media-editor-text-font"), backgroundStyle: .blur)
|
|
public var textStyle: MediaTextView.TextStyle = .regular
|
|
|
|
public var textForegroundColor: UIColor {
|
|
switch decorationStyle {
|
|
case .none: return colorPickerView.color
|
|
case .inverted, .outline, .underline: return .white
|
|
}
|
|
}
|
|
public var textBackgroundColor: UIColor? {
|
|
switch decorationStyle {
|
|
case .none, .underline, .outline: return nil
|
|
case .inverted: return colorPickerView.color
|
|
}
|
|
}
|
|
public var textDecorationColor: UIColor? {
|
|
switch decorationStyle {
|
|
case .none, .inverted: return nil
|
|
case .outline, .underline: return colorPickerView.color
|
|
}
|
|
}
|
|
|
|
public let decorationStyleButton = RoundMediaButton(image: #imageLiteral(resourceName: "media-editor-text-style-1"), backgroundStyle: .blur)
|
|
public var decorationStyle: MediaTextView.DecorationStyle = .none {
|
|
didSet {
|
|
decorationStyleButton.isSelected = (decorationStyle != .none)
|
|
// Change default decoration style away from white to avoid white on white situation.
|
|
if decorationStyle != .none && colorPickerView.selectedValue == .white {
|
|
colorPickerView.selectedValue = .black
|
|
}
|
|
// Switch color picker color back to white because it now controls text color.
|
|
// The idea is that cycling through decoration styles should keep text white.
|
|
if decorationStyle == .none && colorPickerView.selectedValue == .black {
|
|
colorPickerView.selectedValue = .white
|
|
}
|
|
}
|
|
}
|
|
|
|
public lazy var doneButton = RoundMediaButton(image: UIImage(imageLiteralResourceName: "check-24"), backgroundStyle: .blur)
|
|
|
|
public init(layout: Layout, currentColor: ColorPickerBarColor? = nil) {
|
|
self.layout = layout
|
|
colorPickerView = ColorPickerBarView(currentColor: currentColor ?? TextStylingToolbar.defaultColor(forLayout: layout))
|
|
|
|
super.init(frame: .zero)
|
|
|
|
colorPickerView.delegate = self
|
|
|
|
decorationStyleButton.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
decorationStyleButton.setImage(#imageLiteral(resourceName: "media-editor-text-style-2"), for: .selected)
|
|
|
|
// A container with width capped at a predefined size,
|
|
// centered in superview and constrained to layout margins.
|
|
let stackViewLayoutGuide = UILayoutGuide()
|
|
addLayoutGuide(stackViewLayoutGuide)
|
|
addConstraints([
|
|
stackViewLayoutGuide.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
stackViewLayoutGuide.leadingAnchor.constraint(greaterThanOrEqualTo: layoutMarginsGuide.leadingAnchor),
|
|
stackViewLayoutGuide.topAnchor.constraint(equalTo: topAnchor),
|
|
stackViewLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2) ])
|
|
addConstraint({
|
|
let constraint = stackViewLayoutGuide.widthAnchor.constraint(equalToConstant: ImageEditorViewController.preferredToolbarContentWidth)
|
|
constraint.priority = .defaultHigh
|
|
return constraint
|
|
}())
|
|
|
|
// I had to use a custom layout guide because stack view isn't centered
|
|
// but instead has slight offset towards the trailing edge.
|
|
let stackViewSubviews: [UIView] = {
|
|
switch layout {
|
|
case .photoOverlay:
|
|
return [ colorPickerView, textStyleButton, decorationStyleButton ]
|
|
case .textStory:
|
|
return [ textStyleButton, decorationStyleButton, colorPickerView, doneButton ]
|
|
}
|
|
}()
|
|
let stackView = UIStackView(arrangedSubviews: stackViewSubviews)
|
|
stackView.translatesAutoresizingMaskIntoConstraints = false
|
|
stackView.alignment = .center
|
|
stackView.spacing = 8
|
|
stackView.setCustomSpacing(0, after: textStyleButton)
|
|
addSubview(stackView)
|
|
|
|
// Round buttons have no-zero layout margins. Use values of those margins
|
|
// to offset button positions so that they appear properly aligned.
|
|
var leadingMargin: CGFloat = 0
|
|
var trailingMargin: CGFloat = 0
|
|
if let button = stackViewSubviews.first as? RoundMediaButton {
|
|
leadingMargin = button.layoutMargins.leading
|
|
}
|
|
if let button = stackViewSubviews.last as? RoundMediaButton {
|
|
trailingMargin = button.layoutMargins.trailing
|
|
}
|
|
addConstraints([
|
|
stackView.leadingAnchor.constraint(equalTo: stackViewLayoutGuide.leadingAnchor, constant: -leadingMargin),
|
|
stackView.trailingAnchor.constraint(equalTo: stackViewLayoutGuide.trailingAnchor, constant: trailingMargin),
|
|
stackView.topAnchor.constraint(equalTo: stackViewLayoutGuide.topAnchor),
|
|
stackView.bottomAnchor.constraint(equalTo: stackViewLayoutGuide.bottomAnchor) ])
|
|
}
|
|
|
|
@available(iOS, unavailable, message: "Use init(currentColor:)")
|
|
required init(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
}
|
|
|
|
extension TextStylingToolbar: ColorPickerBarViewDelegate {
|
|
|
|
public func colorPickerBarView(_ pickerView: ColorPickerBarView, didSelectColor color: ColorPickerBarColor) {
|
|
sendActions(for: .valueChanged)
|
|
}
|
|
}
|