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

278 lines
11 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
public class MediaTextView: UITextView {
public enum DecorationStyle: String, CaseIterable {
case none // colored text, no background
case whiteBackground // colored text, white background
case coloredBackground // white text, colored background
case underline // white text, colored underline
case outline // white text, colored outline
}
// Resource names are derived from these values. Do not change without consideration.
public enum TextStyle: String, CaseIterable {
case regular
case bold
case serif
case script
case condensed
}
class func font(for textStyle: TextStyle, withPointSize pointSize: CGFloat) -> UIFont {
let style: TextAttachment.TextStyle = {
switch textStyle {
case .regular: return .regular
case .bold: return .bold
case .serif: return .serif
case .script: return .script
case .condensed: return .condensed
}
}()
return UIFont.font(for: style, withPointSize: pointSize)
}
private var kvoObservation: NSKeyValueObservation?
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
self.disableAiWritingTools()
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(for: textStylingToolbar.textStyle, withPointSize: 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
tintColor = textForegroundColor
invalidateIntrinsicContentSize()
}
// MARK: - Key Commands
public override var keyCommands: [UIKeyCommand]? {
return [
UIKeyCommand(action: #selector(modifiedReturnPressed(sender:)), input: "\r", modifierFlags: .command, discoverabilityTitle: "Add Text"),
UIKeyCommand(action: #selector(modifiedReturnPressed(sender:)), input: "\r", modifierFlags: .alternate, discoverabilityTitle: "Add Text")
]
}
@objc
private func modifiedReturnPressed(sender: UIKeyCommand) {
acceptAutocorrectSuggestion()
resignFirstResponder()
}
}
public class TextStylingToolbar: UIControl {
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 let textStyleButton = RoundMediaButton(image: TextStylingToolbar.buttonImage(forTextStyle: .regular),
backgroundStyle: .blur)
public var textStyle: MediaTextView.TextStyle = .regular {
didSet {
textStyleButton.setImage(TextStylingToolbar.buttonImage(forTextStyle: textStyle), for: .normal)
}
}
private static func buttonImage(forTextStyle textStyle: MediaTextView.TextStyle) -> UIImage? {
return UIImage(imageLiteralResourceName: "font-" + textStyle.rawValue)
}
public var textForegroundColor: UIColor {
switch decorationStyle {
case .none, .whiteBackground: return colorPickerView.color
case .coloredBackground:
// Switch text color to black if background is almost white.
let backgroundColor = colorPickerView.color
return backgroundColor.isCloseToColor(.white) ? .black : .white
case .outline, .underline: return .white
}
}
public var textBackgroundColor: UIColor? {
switch decorationStyle {
case .none, .underline, .outline: return nil
case .whiteBackground:
// Switch background color to black if text color is almost white.
let textColor = colorPickerView.color
return textColor.isCloseToColor(.white) ? .black : .white
case .coloredBackground: return colorPickerView.color
}
}
public var textDecorationColor: UIColor? {
switch decorationStyle {
case .none, .whiteBackground, .coloredBackground: return nil
case .outline, .underline: return colorPickerView.color
}
}
public let decorationStyleButton = RoundMediaButton(
image: UIImage(imageLiteralResourceName: "text_effects"),
backgroundStyle: .blur
)
public var decorationStyle: MediaTextView.DecorationStyle = .none {
didSet {
decorationStyleButton.isSelected = (decorationStyle != .none)
}
}
public lazy var doneButton = RoundMediaButton(image: Theme.iconImage(.checkmark), backgroundStyle: .blur)
public private(set) var contentWidthConstraint: NSLayoutConstraint?
private lazy var stackView: UIStackView = {
let stackView = UIStackView(arrangedSubviews: [ textStyleButton, decorationStyleButton, colorPickerView, doneButton ])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.alignment = .center
stackView.spacing = 8
stackView.setCustomSpacing(0, after: textStyleButton)
return stackView
}()
public init(currentColor: ColorPickerBarColor? = nil) {
colorPickerView = ColorPickerBarView(currentColor: currentColor ?? ColorPickerBarColor.white)
super.init(frame: .zero)
autoresizingMask = [ .flexibleHeight ]
colorPickerView.delegate = self
decorationStyleButton.setContentCompressionResistancePriority(.required, for: .vertical)
decorationStyleButton.setImage(UIImage(imageLiteralResourceName: "text_effects-fill"), for: .selected)
// A container with width capped at a predefined size,
// centered in superview and constrained to layout margins.
let stackViewLayoutGuide = UILayoutGuide()
let contentWidthConstraint = stackViewLayoutGuide.widthAnchor.constraint(equalToConstant: ImageEditorViewController.preferredToolbarContentWidth)
contentWidthConstraint.priority = .defaultHigh
self.contentWidthConstraint = contentWidthConstraint
addLayoutGuide(stackViewLayoutGuide)
addConstraints([
stackViewLayoutGuide.centerXAnchor.constraint(equalTo: centerXAnchor),
stackViewLayoutGuide.leadingAnchor.constraint(greaterThanOrEqualTo: layoutMarginsGuide.leadingAnchor),
stackViewLayoutGuide.topAnchor.constraint(equalTo: topAnchor),
stackViewLayoutGuide.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -2),
contentWidthConstraint
])
// I had to use a custom layout guide because stack view isn't centered
// but instead has slight offset towards the trailing edge.
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 = stackView.arrangedSubviews.first as? RoundMediaButton {
leadingMargin = button.layoutMargins.leading
}
if let button = stackView.arrangedSubviews.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")
}
public override var intrinsicContentSize: CGSize {
// NOTE: Update size calculation if changing margins around UIStackView in init(layout:currentColor:).
CGSize(
width: UIScreen.main.bounds.width,
height: stackView.frame.height + 2 + safeAreaInsets.bottom
)
}
}
extension TextStylingToolbar: ColorPickerBarViewDelegate {
public func colorPickerBarView(_ pickerView: ColorPickerBarView, didSelectColor color: ColorPickerBarColor) {
sendActions(for: .valueChanged)
}
}