// // CollapsingTextView.swift // AltStore // // Created by Riley Testut on 7/23/19. // Copyright © 2019 Riley Testut. All rights reserved. // import UIKit final class CollapsingTextView: UITextView { var isCollapsed = true { didSet { guard self.isCollapsed != oldValue else { return } self.setNeedsLayout() } } var maximumNumberOfLines = 2 { didSet { self.setNeedsLayout() } } var lineSpacing: Double = 2 { didSet { if #available(iOS 16, *) { self.updateText() } else { self.setNeedsLayout() } } } override var text: String! { didSet { guard #available(iOS 16, *) else { return } self.updateText() } } let moreButton = UIButton(type: .system) override init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: frame, textContainer: textContainer) self.initialize() } required init?(coder: NSCoder) { super.init(coder: coder) } override func awakeFromNib() { super.awakeFromNib() self.initialize() } private func initialize() { if #available(iOS 16, *) { self.updateText() } else { self.layoutManager.delegate = self } self.textContainerInset = .zero self.textContainer.lineFragmentPadding = 0 self.textContainer.lineBreakMode = .byTruncatingTail self.textContainer.heightTracksTextView = true self.textContainer.widthTracksTextView = true self.moreButton.setTitle(NSLocalizedString("More", comment: ""), for: .normal) self.moreButton.addTarget(self, action: #selector(CollapsingTextView.toggleCollapsed(_:)), for: .primaryActionTriggered) self.addSubview(self.moreButton) self.setNeedsLayout() } override func layoutSubviews() { super.layoutSubviews() guard let font = self.font else { return } let buttonFont = UIFont.systemFont(ofSize: font.pointSize, weight: .medium) self.moreButton.titleLabel?.font = buttonFont let buttonY = (font.lineHeight + self.lineSpacing) * CGFloat(self.maximumNumberOfLines - 1) let size = self.moreButton.sizeThatFits(CGSize(width: 1000, height: 1000)) let moreButtonFrame = CGRect(x: self.bounds.width - self.moreButton.bounds.width, y: buttonY, width: size.width, height: font.lineHeight) self.moreButton.frame = moreButtonFrame if self.isCollapsed { let boundingSize = self.attributedText.boundingRect(with: CGSize(width: self.textContainer.size.width, height: .infinity), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil) let maximumCollapsedHeight = font.lineHeight * Double(self.maximumNumberOfLines) + self.lineSpacing * Double(self.maximumNumberOfLines - 1) if boundingSize.height.rounded() > maximumCollapsedHeight.rounded() { self.textContainer.maximumNumberOfLines = self.maximumNumberOfLines var exclusionFrame = moreButtonFrame exclusionFrame.origin.y += self.moreButton.bounds.midY exclusionFrame.size.width = self.bounds.width // Extra wide to make sure it wraps to next line. self.textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)] self.moreButton.isHidden = false } else { self.textContainer.maximumNumberOfLines = 0 // Fixes last line having slightly smaller line spacing. self.textContainer.exclusionPaths = [] self.moreButton.isHidden = true } } else { self.textContainer.maximumNumberOfLines = 0 self.textContainer.exclusionPaths = [] self.moreButton.isHidden = true } self.invalidateIntrinsicContentSize() } } private extension CollapsingTextView { @objc func toggleCollapsed(_ sender: UIButton) { self.isCollapsed.toggle() } @available(iOS 16, *) func updateText() { do { let style = NSMutableParagraphStyle() style.lineSpacing = self.lineSpacing var attributedText = try AttributedString(self.attributedText, including: \.uiKit) attributedText[AttributeScopes.UIKitAttributes.ParagraphStyleAttribute.self] = style self.attributedText = NSAttributedString(attributedText) } catch { print("[ALTLog] Failed to update CollapsingTextView line spacing:", error) } } } extension CollapsingTextView: NSLayoutManagerDelegate { func layoutManager(_ layoutManager: NSLayoutManager, lineSpacingAfterGlyphAt glyphIndex: Int, withProposedLineFragmentRect rect: CGRect) -> CGFloat { return self.lineSpacing } }