diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 5a609ae6..42db805a 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -87,6 +87,9 @@ A8AD35592D31BF2C003A28B4 /* PageInfoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8AD35582D31BF29003A28B4 /* PageInfoManager.swift */; }; A8B516E32D2666CA0047047C /* CoreDataHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8B516E22D2666CA0047047C /* CoreDataHelper.swift */; }; A8B516E62D2668170047047C /* DateTimeUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8B516E52D2668020047047C /* DateTimeUtil.swift */; }; + A8B645FC2D70C10300125819 /* CollapsingMarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8B645FB2D70C10300125819 /* CollapsingMarkdownView.swift */; }; + A8B645FF2D70C1AD00125819 /* MarkdownKit in Frameworks */ = {isa = PBXBuildFile; productRef = A8B645FE2D70C1AD00125819 /* MarkdownKit */; }; + A8B646012D70C23E00125819 /* MarkdownKit in Frameworks */ = {isa = PBXBuildFile; productRef = A8B646002D70C23E00125819 /* MarkdownKit */; }; A8BB34E52D04EC8E000A8B4D /* minimuxer-helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = A809F6A52D04DA1900F0F0F3 /* minimuxer-helpers.swift */; }; A8C38C242D206A3A00E83DBD /* ConsoleLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C38C1D2D206A3A00E83DBD /* ConsoleLogger.swift */; }; A8C38C262D206A3A00E83DBD /* ConsoleLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C38C1E2D206A3A00E83DBD /* ConsoleLog.swift */; }; @@ -686,6 +689,7 @@ A8AD35582D31BF29003A28B4 /* PageInfoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageInfoManager.swift; sourceTree = ""; }; A8B516E22D2666CA0047047C /* CoreDataHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHelper.swift; sourceTree = ""; }; A8B516E52D2668020047047C /* DateTimeUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTimeUtil.swift; sourceTree = ""; }; + A8B645FB2D70C10300125819 /* CollapsingMarkdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsingMarkdownView.swift; sourceTree = ""; }; A8C38C1D2D206A3A00E83DBD /* ConsoleLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleLogger.swift; sourceTree = ""; }; A8C38C1E2D206A3A00E83DBD /* ConsoleLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleLog.swift; sourceTree = ""; }; A8C38C282D206AC100E83DBD /* OutputStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputStream.swift; sourceTree = ""; }; @@ -1137,6 +1141,7 @@ files = ( A8C6D5172D1EE95B00DF01F1 /* OpenSSL.xcframework in Frameworks */, A8A543302D04F14400D72399 /* libfragmentzip.a in Frameworks */, + A8B646012D70C23E00125819 /* MarkdownKit in Frameworks */, A805C3CD2D0C316A00E76BDD /* Pods_SideStore.framework in Frameworks */, A8F838942D048ECE00ED425D /* libimobiledevice.a in Frameworks */, A8F838922D048E8F00ED425D /* libEmotionalDamage.a in Frameworks */, @@ -1144,6 +1149,7 @@ D533E8B72727841800A9B5DD /* libAppleArchive.tbd in Frameworks */, D533E8BE2727BBF800A9B5DD /* libcurl.a in Frameworks */, BF1614F1250822F100767AEA /* Roxas.framework in Frameworks */, + A8B645FF2D70C1AD00125819 /* MarkdownKit in Frameworks */, BF66EE852501AE50007EE018 /* AltStoreCore.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1346,6 +1352,22 @@ path = database; sourceTree = ""; }; + A8B645F82D70C0DD00125819 /* Views */ = { + isa = PBXGroup; + children = ( + A8B645FA2D70C0F600125819 /* UIKit */, + ); + path = Views; + sourceTree = ""; + }; + A8B645FA2D70C0F600125819 /* UIKit */ = { + isa = PBXGroup; + children = ( + A8B645FB2D70C10300125819 /* CollapsingMarkdownView.swift */, + ); + path = UIKit; + sourceTree = ""; + }; A8C38C1C2D2068D100E83DBD /* Utils */ = { isa = PBXGroup; children = ( @@ -1427,6 +1449,7 @@ A8F66C072D04C025009689E6 /* SideStore */ = { isa = PBXGroup; children = ( + A8B645F82D70C0DD00125819 /* Views */, A8E2DB352D6850A9009E5D31 /* Tests */, A8F66C5C2D04D433009689E6 /* minimuxer */, A8F66C602D04D464009689E6 /* minimuxer.xcodeproj */, @@ -2682,6 +2705,7 @@ D58D5F2C26DFE68E00E55E38 /* XCRemoteSwiftPackageReference "LaunchAtLogin" */, D5FB7A2C2AA2859400EF863D /* XCRemoteSwiftPackageReference "swift-argument-parser" */, A82067C22D03E0DE00645C0D /* XCRemoteSwiftPackageReference "SemanticVersion" */, + A8B645FD2D70C1AD00125819 /* XCRemoteSwiftPackageReference "MarkdownKit" */, ); productRefGroup = BFD2476B2284B9A500981D42 /* Products */; projectDirPath = ""; @@ -3292,6 +3316,7 @@ D57FE84428C7DB7100216002 /* ErrorLogViewController.swift in Sources */, A88B8C552D35F1EC00F53F9D /* OperationsLoggingControl.swift in Sources */, D50107EC2ADF2E1A0069F2A1 /* AddSourceTextFieldCell.swift in Sources */, + A8B645FC2D70C10300125819 /* CollapsingMarkdownView.swift in Sources */, D5151BD92A8FF64300C96F28 /* RefreshAllAppsIntent.swift in Sources */, BFBE0007250AD0E70080826E /* ViewAppIntentHandler.swift in Sources */, BFDB6A0822AAED73007EA6D6 /* ResignAppOperation.swift in Sources */, @@ -4259,6 +4284,14 @@ minimumVersion = 0.4.0; }; }; + A8B645FD2D70C1AD00125819 /* XCRemoteSwiftPackageReference "MarkdownKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/bmoliveira/MarkdownKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.7.1; + }; + }; D58D5F2C26DFE68E00E55E38 /* XCRemoteSwiftPackageReference "LaunchAtLogin" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/sindresorhus/LaunchAtLogin.git"; @@ -4283,6 +4316,16 @@ package = A82067C22D03E0DE00645C0D /* XCRemoteSwiftPackageReference "SemanticVersion" */; productName = SemanticVersion; }; + A8B645FE2D70C1AD00125819 /* MarkdownKit */ = { + isa = XCSwiftPackageProductDependency; + package = A8B645FD2D70C1AD00125819 /* XCRemoteSwiftPackageReference "MarkdownKit" */; + productName = MarkdownKit; + }; + A8B646002D70C23E00125819 /* MarkdownKit */ = { + isa = XCSwiftPackageProductDependency; + package = A8B645FD2D70C1AD00125819 /* XCRemoteSwiftPackageReference "MarkdownKit" */; + productName = MarkdownKit; + }; A8C6D50B2D1EE87600DF01F1 /* AltSign-Static */ = { isa = XCSwiftPackageProductDependency; productName = "AltSign-Static"; diff --git a/AltStore/App Detail/AppContentViewController.swift b/AltStore/App Detail/AppContentViewController.swift index 692c2557..8ba1a195 100644 --- a/AltStore/App Detail/AppContentViewController.swift +++ b/AltStore/App Detail/AppContentViewController.swift @@ -43,8 +43,10 @@ final class AppContentViewController: UITableViewController }() @IBOutlet private var subtitleLabel: UILabel! - @IBOutlet private var descriptionTextView: CollapsingTextView! - @IBOutlet private var versionDescriptionTextView: CollapsingTextView! +// @IBOutlet private var descriptionTextView: CollapsingTextView! + @IBOutlet private var descriptionTextView: CollapsingMarkdownView! +// @IBOutlet private var versionDescriptionTextView: CollapsingTextView! + @IBOutlet private var versionDescriptionTextView: CollapsingMarkdownView! @IBOutlet private var versionLabel: UILabel! @IBOutlet private var versionDateLabel: UILabel! @IBOutlet private var sizeLabel: UILabel! @@ -55,35 +57,32 @@ final class AppContentViewController: UITableViewController @IBOutlet private(set) var appDetailCollectionViewController: AppDetailCollectionViewController! @IBOutlet private var appDetailCollectionViewHeightConstraint: NSLayoutConstraint! - override func viewDidLoad() - { + override func viewDidLoad() { super.viewDidLoad() self.tableView.contentInset.bottom = 20 self.subtitleLabel.text = self.app.subtitle - self.descriptionTextView.text = self.app.localizedDescription + let desc = self.app.localizedDescription + self.descriptionTextView.text = desc - if let version = self.app.latestAvailableVersion - { - self.versionDescriptionTextView.text = version.localizedDescription + if let version = self.app.latestAvailableVersion { + self.versionDescriptionTextView.text = version.localizedDescription ?? "nil" self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), version.localizedVersion) self.versionDateLabel.text = Date().relativeDateString(since: version.date) - self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: version.size) - } - else - { - self.versionDescriptionTextView.text = nil + self.sizeLabel.text = ByteCountFormatter.string(fromByteCount: version.size, countStyle: .file) + } else { + self.versionDescriptionTextView.text = "nil" self.versionLabel.text = nil self.versionDateLabel.text = nil - self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: 0) + self.sizeLabel.text = ByteCountFormatter.string(fromByteCount: 0, countStyle: .file) } self.descriptionTextView.maximumNumberOfLines = 5 - self.descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) + self.versionDescriptionTextView.maximumNumberOfLines = 5 - self.versionDescriptionTextView.maximumNumberOfLines = 3 - self.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) + self.descriptionTextView.toggleButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) + self.versionDescriptionTextView.toggleButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) } override func viewDidLayoutSubviews() @@ -162,8 +161,12 @@ private extension AppContentViewController switch sender { - case self.descriptionTextView.moreButton: indexPath = IndexPath(row: Row.description.rawValue, section: 0) - case self.versionDescriptionTextView.moreButton: indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0) + case self.descriptionTextView.toggleButton: + indexPath = IndexPath(row: Row.description.rawValue, section: 0) + + case self.versionDescriptionTextView.toggleButton: + indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0) + default: return } diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index 2b60ee1d..d33cf363 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -287,7 +287,7 @@ - + @@ -353,7 +353,7 @@ - + diff --git a/AltStore/Components/CollapsingTextView.swift b/AltStore/Components/CollapsingTextView.swift index cebc8cac..6c74e3d7 100644 --- a/AltStore/Components/CollapsingTextView.swift +++ b/AltStore/Components/CollapsingTextView.swift @@ -13,21 +13,18 @@ final class CollapsingTextView: UITextView var isCollapsed = true { didSet { guard self.isCollapsed != oldValue else { return } - self.shouldResetLayout = true self.setNeedsLayout() } } var maximumNumberOfLines = 2 { didSet { - self.shouldResetLayout = true self.setNeedsLayout() } } var lineSpacing: Double = 2 { didSet { - self.shouldResetLayout = true if #available(iOS 16, *) { @@ -42,7 +39,6 @@ final class CollapsingTextView: UITextView override var text: String! { didSet { - self.shouldResetLayout = true guard #available(iOS 16, *) else { return } self.updateText() @@ -51,9 +47,6 @@ final class CollapsingTextView: UITextView let moreButton = UIButton(type: .system) - private var shouldResetLayout: Bool = false - private var previousSize: CGSize? - override init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: frame, textContainer: textContainer) @@ -115,45 +108,39 @@ final class CollapsingTextView: UITextView height: font.lineHeight) self.moreButton.frame = moreButtonFrame - if self.shouldResetLayout || self.previousSize != self.bounds.size + if self.isCollapsed { - 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() { - 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) + self.textContainer.maximumNumberOfLines = self.maximumNumberOfLines - 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 - } + 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 + 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.invalidateIntrinsicContentSize() + self.moreButton.isHidden = true } - self.shouldResetLayout = false - self.previousSize = self.bounds.size + self.invalidateIntrinsicContentSize() } } diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index 2cdb8df5..b4a0851b 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -235,7 +235,8 @@ private extension MyAppsViewController cell.layoutMargins.right = self.view.layoutMargins.right cell.tintColor = app.tintColor ?? .altPrimary - cell.versionDescriptionTextView.text = latestSupportedVersion.localizedDescription + cell.versionDescriptionTextView.maximumNumberOfLines = 2 + cell.versionDescriptionTextView.text = latestSupportedVersion.localizedDescription ?? "nil" cell.bannerView.iconImageView.image = nil cell.bannerView.iconImageView.isIndicatingActivity = true @@ -281,12 +282,9 @@ private extension MyAppsViewController cell.mode = .collapsed } - cell.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(MyAppsViewController.toggleUpdateCellMode(_:)), for: .primaryActionTriggered) - + cell.versionDescriptionTextView.toggleButton.addTarget(self, action: #selector(MyAppsViewController.toggleUpdateCellMode(_:)), for: .primaryActionTriggered) + cell.setNeedsLayout() - - // Below lines are necessary to avoid "more" button layout issues. - cell.versionDescriptionTextView.setNeedsLayout() cell.layoutIfNeeded() } dataSource.prefetchHandler = { (installedApp, indexPath, completionHandler) in @@ -724,22 +722,29 @@ private extension MyAppsViewController let cell = self.collectionView.cellForItem(at: indexPath) as? UpdateCollectionViewCell + // Toggle the state if self.expandedAppUpdates.contains(installedApp.bundleIdentifier) { self.expandedAppUpdates.remove(installedApp.bundleIdentifier) + // Set collapsed mode on the cell cell?.mode = .collapsed } else { self.expandedAppUpdates.insert(installedApp.bundleIdentifier) + // Set expanded mode on the cell cell?.mode = .expanded } + // Clear cached size so it's recalculated self.cachedUpdateSizes[installedApp.bundleIdentifier] = nil - self.collectionView.performBatchUpdates({ - self.collectionView.collectionViewLayout.invalidateLayout() - }, completion: nil) + // Animate the change smoothly with a duration + UIView.animate(withDuration: 0.25) { + self.collectionView.performBatchUpdates({ + self.collectionView.collectionViewLayout.invalidateLayout() + }, completion: nil) + } } @IBAction func refreshApp(_ sender: UIButton) diff --git a/AltStore/My Apps/UpdateCollectionViewCell.swift b/AltStore/My Apps/UpdateCollectionViewCell.swift index 7e72ca32..32b82770 100644 --- a/AltStore/My Apps/UpdateCollectionViewCell.swift +++ b/AltStore/My Apps/UpdateCollectionViewCell.swift @@ -21,12 +21,19 @@ extension UpdateCollectionViewCell { var mode: Mode = .expanded { didSet { - self.update() + switch self.mode { + case .collapsed: + self.versionDescriptionTextView.isCollapsed = true + case .expanded: + self.versionDescriptionTextView.isCollapsed = false + } + self.setNeedsLayout() } } @IBOutlet var bannerView: AppBannerView! - @IBOutlet var versionDescriptionTextView: CollapsingTextView! +// @IBOutlet var versionDescriptionTextView: CollapsingTextView! + @IBOutlet var versionDescriptionTextView: CollapsingMarkdownView! @IBOutlet private var blurView: UIVisualEffectView! @@ -85,16 +92,16 @@ extension UpdateCollectionViewCell } } - override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize - { - // Ensure cell is laid out so it will report correct size. - self.versionDescriptionTextView.setNeedsLayout() - self.versionDescriptionTextView.layoutIfNeeded() - - let size = super.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: horizontalFittingPriority, verticalFittingPriority: verticalFittingPriority) - - return size - } +// override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize +// { +// // Ensure cell is laid out so it will report correct size. +// self.versionDescriptionTextView.setNeedsLayout() +// self.versionDescriptionTextView.layoutIfNeeded() +// +// let size = super.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: horizontalFittingPriority, verticalFittingPriority: verticalFittingPriority) +// +// return size +// } } private extension UpdateCollectionViewCell diff --git a/AltStore/My Apps/UpdateCollectionViewCell.xib b/AltStore/My Apps/UpdateCollectionViewCell.xib index 2330ed32..117e0338 100644 --- a/AltStore/My Apps/UpdateCollectionViewCell.xib +++ b/AltStore/My Apps/UpdateCollectionViewCell.xib @@ -1,9 +1,9 @@ - + - + @@ -11,7 +11,7 @@ - + @@ -30,7 +30,7 @@ - + @@ -39,7 +39,7 @@ - + @@ -91,7 +91,7 @@ - + diff --git a/SideStore/Views/UIKit/CollapsingMarkdownView.swift b/SideStore/Views/UIKit/CollapsingMarkdownView.swift new file mode 100644 index 00000000..2c4a2a9f --- /dev/null +++ b/SideStore/Views/UIKit/CollapsingMarkdownView.swift @@ -0,0 +1,301 @@ +// +// CollapsingMarkdownView.swift +// AltStore +// +// Created by Magesh K on 27/02/25. +// Copyright © 2025 SideStore. All rights reserved. +// + + +import UIKit +import MarkdownKit + +struct MarkdownManager +{ + struct Fonts{ + static let body: UIFont = .systemFont(ofSize: UIFont.systemFontSize) +// static let body: UIFont = .systemFont(ofSize: UIFont.labelFontSize) + + static let header: UIFont = .boldSystemFont(ofSize: 14) + static let list: UIFont = .systemFont(ofSize: 14) + static let bold: UIFont = .boldSystemFont(ofSize: 14) + static let italic: UIFont = .italicSystemFont(ofSize: 14) + static let quote: UIFont = .italicSystemFont(ofSize: 14) + } + + struct Color{ + static let header = UIColor { traitCollection in + traitCollection.userInterfaceStyle == .dark ? UIColor.white : UIColor.black + } + static let bold = UIColor { traitCollection in + traitCollection.userInterfaceStyle == .dark ? UIColor.lightText : UIColor.darkText + } + } + + static var enabledElements: MarkdownParser.EnabledElements { + [ + .header, + .list, + .quote, + .code, + .link, + .bold, + .italic, + ] + } + + var markdownParser: MarkdownParser { + MarkdownParser( + font: Self.Fonts.body, + color: Self.Color.bold + ) + } +} +final class CollapsingMarkdownView: UIView { + /// Called when the collapse state toggles. + var didToggleCollapse: (() -> Void)? + + // MARK: - Properties + var isCollapsed = true { + didSet { + guard self.isCollapsed != oldValue else { return } + self.updateCollapsedState() + } + } + + var maximumNumberOfLines = 3 { + didSet { + self.checkIfNeedsCollapsing() + self.updateCollapsedState() + self.setNeedsLayout() + } + } + + var text: String = "" { + didSet { + self.updateMarkdownContent() + self.setNeedsLayout() + } + } + + var lineSpacing: Double = 2 { + didSet { + self.setNeedsLayout() + } + } + + let toggleButton = UIButton(type: .system) + + private let textView = UITextView() + private let markdownParser = MarkdownManager().markdownParser + + private var previousSize: CGSize? + private var actualLineCount: Int = 0 + private var needsCollapsing = false + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + initialize() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + initialize() + } + + override func awakeFromNib() { + super.awakeFromNib() + initialize() + } + + private func checkIfNeedsCollapsing() { + guard bounds.width > 0, let font = textView.font, font.lineHeight > 0 else { + needsCollapsing = false + return + } + + // Calculate the number of lines in the text + let textSize = textView.sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude)) + let lineHeight = font.lineHeight + + // Safely calculate actual line count + actualLineCount = max(1, Int(ceil(textSize.height / lineHeight))) + + // Only needs collapsing if actual lines exceed the maximum + needsCollapsing = actualLineCount > maximumNumberOfLines + + // Update button visibility + toggleButton.isHidden = !needsCollapsing + } + + private func updateCollapsedState() { + // Disable animations for this update + UIView.performWithoutAnimation { + // Update the button title + let title = isCollapsed ? NSLocalizedString("More", comment: "") : NSLocalizedString("Less", comment: "") + toggleButton.setTitle(title, for: .normal) + + // Set max lines based on collapsed state + if isCollapsed && needsCollapsing { + textView.textContainer.maximumNumberOfLines = maximumNumberOfLines + } else { + textView.textContainer.maximumNumberOfLines = 0 + } + + // Button is only visible if content needs collapsing + toggleButton.isHidden = !needsCollapsing + + // Force layout updates + textView.layoutIfNeeded() + self.layoutIfNeeded() + self.invalidateIntrinsicContentSize() + } + } + + private func initialize() { + // Configure text view + textView.isEditable = false + textView.isScrollEnabled = false + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.textContainer.lineBreakMode = .byTruncatingTail + textView.backgroundColor = .clear + + // Make textView selectable to enable link interactions + textView.isSelectable = true + textView.delegate = self + + // Important: This prevents selection handles from appearing + textView.dataDetectorTypes = .link + + // Configure markdown parser + configureMarkdownParser() + + // Add subviews + addSubview(textView) + + // Configure toggle button + toggleButton.addTarget(self, action: #selector(toggleCollapsed(_:)), for: .primaryActionTriggered) + addSubview(toggleButton) + + setNeedsLayout() + } + + private func configureMarkdownParser() { + // Configure markdown parser with desired settings + markdownParser.enabledElements = MarkdownManager.enabledElements + + // You can also customize the styling if needed + markdownParser.header.font = MarkdownManager.Fonts.header + markdownParser.list.font = MarkdownManager.Fonts.list + markdownParser.bold.font = MarkdownManager.Fonts.bold + markdownParser.italic.font = MarkdownManager.Fonts.italic + markdownParser.quote.font = MarkdownManager.Fonts.quote + + markdownParser.header.color = MarkdownManager.Color.header + markdownParser.bold.color = MarkdownManager.Color.bold + markdownParser.list.color = MarkdownManager.Color.bold + } + + // MARK: - Layout + override func layoutSubviews() { + super.layoutSubviews() + + UIView.performWithoutAnimation { + // Calculate button height (for spacing) + let buttonHeight = toggleButton.sizeThatFits(CGSize(width: 1000, height: 1000)).height + + // Set textView frame to leave space for button + textView.frame = CGRect( + x: 0, + y: 0, + width: bounds.width, + height: bounds.height - buttonHeight + ) + + // Check if layout changed + if previousSize?.width != bounds.width { + checkIfNeedsCollapsing() + updateCollapsedState() + previousSize = bounds.size + } + + // Position toggle button at bottom right + let buttonSize = toggleButton.sizeThatFits(CGSize(width: 1000, height: 1000)) + toggleButton.frame = CGRect( + x: bounds.width - buttonSize.width, + y: textView.frame.maxY, + width: buttonSize.width, + height: buttonHeight + ) + } + } + + @objc private func toggleCollapsed(_ sender: UIButton) { + isCollapsed.toggle() + didToggleCollapse?() + } + + override var intrinsicContentSize: CGSize { + guard bounds.width > 0, let font = textView.font, font.lineHeight > 0 else { + return CGSize(width: UIView.noIntrinsicMetric, height: 0) + } + + let lineHeight = font.lineHeight + let buttonHeight = toggleButton.sizeThatFits(CGSize(width: 1000, height: 1000)).height + + // Always add button height to reserve space for it + if isCollapsed && needsCollapsing { + // When collapsed and needs collapsing, use maximumNumberOfLines + let collapsedHeight = lineHeight * CGFloat(maximumNumberOfLines) + + lineSpacing * CGFloat(max(0, maximumNumberOfLines - 1)) + return CGSize(width: UIView.noIntrinsicMetric, height: collapsedHeight + buttonHeight) + } else if !needsCollapsing { + // Text is shorter than max lines - use actual text height + let textSize = textView.sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude)) + return CGSize(width: UIView.noIntrinsicMetric, height: textSize.height + buttonHeight) + } else { + // When expanded and needs collapsing, use full text height plus button + let textSize = textView.sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude)) + return CGSize(width: UIView.noIntrinsicMetric, height: textSize.height + buttonHeight) + } + } + + // MARK: - Markdown Processing + private func updateMarkdownContent() { + let attributedString = markdownParser.parse(text) + + // Apply line spacing + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = lineSpacing + + mutableAttributedString.addAttribute( + .paragraphStyle, + value: paragraphStyle, + range: NSRange(location: 0, length: mutableAttributedString.length) + ) + + textView.attributedText = mutableAttributedString + + // Check if content needs collapsing after setting text + checkIfNeedsCollapsing() + updateCollapsedState() + } +} + +extension CollapsingMarkdownView: UITextViewDelegate { + // This enables tapping on links while preventing text selection + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + // Open the URL using UIApplication + UIApplication.shared.open(URL) + return false // Return false to prevent the default behavior + } + + // This prevents text selection + func textViewDidChangeSelection(_ textView: UITextView) { + textView.selectedTextRange = nil + } +}