Signal-iOS/SignalUI/Views/MediaTextView.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 }
}
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 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)
}
}