Signal-iOS/SignalUI/Views/MediaTextView.swift
Igor Solomennikov a6ff75a24c
Text story composer
* Hide camera controls when switching to TEXT.

* Streamline camera capture session state tracking.

Stop camera when switching to TEXT.

* Basic text story composer.

All UI works, but needs fine-tuning and tapping on Next (->) doesn't do anything.

* Run auto-genstrings.

* Adjust text size and alignment as user enters text.

1..49 characters: 34 pt, center-aligned.
50..199 characters: 24 pt, center-aligned.
200.. characters: 18 pt, natural alignment.

* Change default text color in text story composer to white.

* Added support for changing background of text story composer.

Selection is allowed from palette of 5 gradients and 11 solid colors.

* Improve vertical alignment of text in text story composer.

Text (and possible link preview panel in the future) should be vertically
centered in the area above either bottom controls or onscreen keyboard.

* Add UI for attaching a link preview to a text story.

* Add support for posting text stories.

* Lint.
2022-09-12 11:38:28 -07:00

245 lines
9.3 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_Bold"]
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)
update(withColor: textStylingToolbar.colorPickerView.color,
font: font,
textAlignment: textAlignment,
decorationStyle: textStylingToolbar.decorationStyle)
}
public func update(withColor color: UIColor,
font: UIFont,
textAlignment: NSTextAlignment = .center,
decorationStyle: MediaTextView.DecorationStyle) {
var attributes: [NSAttributedString.Key: Any] = [ .font: font]
let textColor: UIColor = {
switch decorationStyle {
case .none: return color
default: return .white
}
}()
attributes[.foregroundColor] = textColor
if let paragraphStyle = NSParagraphStyle.default.mutableCopy() as? NSMutableParagraphStyle {
paragraphStyle.alignment = textAlignment
attributes[.paragraphStyle] = paragraphStyle
}
switch decorationStyle {
case .underline:
attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
attributes[.underlineColor] = color
case .outline:
attributes[.strokeWidth] = -3
attributes[.strokeColor] = color
case .inverted:
attributes[.backgroundColor] = color
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: UIView {
public enum Layout {
case photoOverlay
case textStory
}
let layout: Layout
public let colorPickerView: ColorPickerBarView
private 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 let decorationStyleButton = RoundMediaButton(image: #imageLiteral(resourceName: "media-editor-text-style-1"), backgroundStyle: .blur)
public var decorationStyle: MediaTextView.DecorationStyle = .none {
didSet {
decorationStyleButton.isSelected = (decorationStyle != .none)
}
}
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)
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")
}
}