Merge pull request #897 from SideStore/markdown-for-update-description

Feature: Render in-app update description as (full) markdown
This commit is contained in:
Magesh K
2025-02-28 05:22:37 +05:30
committed by GitHub
8 changed files with 427 additions and 81 deletions

View File

@@ -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 = "<group>"; };
A8B516E22D2666CA0047047C /* CoreDataHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHelper.swift; sourceTree = "<group>"; };
A8B516E52D2668020047047C /* DateTimeUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTimeUtil.swift; sourceTree = "<group>"; };
A8B645FB2D70C10300125819 /* CollapsingMarkdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsingMarkdownView.swift; sourceTree = "<group>"; };
A8C38C1D2D206A3A00E83DBD /* ConsoleLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleLogger.swift; sourceTree = "<group>"; };
A8C38C1E2D206A3A00E83DBD /* ConsoleLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleLog.swift; sourceTree = "<group>"; };
A8C38C282D206AC100E83DBD /* OutputStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputStream.swift; sourceTree = "<group>"; };
@@ -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 = "<group>";
};
A8B645F82D70C0DD00125819 /* Views */ = {
isa = PBXGroup;
children = (
A8B645FA2D70C0F600125819 /* UIKit */,
);
path = Views;
sourceTree = "<group>";
};
A8B645FA2D70C0F600125819 /* UIKit */ = {
isa = PBXGroup;
children = (
A8B645FB2D70C10300125819 /* CollapsingMarkdownView.swift */,
);
path = UIKit;
sourceTree = "<group>";
};
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";

View File

@@ -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
}

View File

@@ -287,7 +287,7 @@
<rect key="frame" x="0.0" y="0.0" width="375" height="98"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" text="Hello Me" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="Pyt-8D-BZA" customClass="CollapsingTextView" customModule="SideStore" customModuleProvider="target">
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" text="Hello Me" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="Pyt-8D-BZA" customClass="CollapsingMarkdownView" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="20" y="20" width="335" height="34"/>
<color key="backgroundColor" name="Background"/>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
@@ -353,7 +353,7 @@
</stackView>
</subviews>
</stackView>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="wQF-WY-Gdz" customClass="CollapsingTextView" customModule="SideStore" customModuleProvider="target">
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="wQF-WY-Gdz" customClass="CollapsingMarkdownView" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="20" y="59.5" width="335" height="34"/>
<color key="backgroundColor" name="Background"/>
<fontDescription key="fontDescription" type="system" pointSize="15"/>

View File

@@ -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,8 +108,6 @@ final class CollapsingTextView: UITextView
height: font.lineHeight)
self.moreButton.frame = moreButtonFrame
if self.shouldResetLayout || self.previousSize != self.bounds.size
{
if self.isCollapsed
{
let boundingSize = self.attributedText.boundingRect(with: CGSize(width: self.textContainer.size.width, height: .infinity), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
@@ -151,10 +142,6 @@ final class CollapsingTextView: UITextView
self.invalidateIntrinsicContentSize()
}
self.shouldResetLayout = false
self.previousSize = self.bounds.size
}
}
private extension CollapsingTextView

View File

@@ -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,23 +722,30 @@ 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
// 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)
{

View File

@@ -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

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="22505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22504"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@@ -11,7 +11,7 @@
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="UpdateCell" id="Kqf-Pv-ca3" customClass="UpdateCollectionViewCell" customModule="AltStore" customModuleProvider="target">
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="UpdateCell" id="Kqf-Pv-ca3" customClass="UpdateCollectionViewCell" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="125"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
@@ -30,7 +30,7 @@
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="uYl-PH-DuP">
<rect key="frame" x="0.0" y="0.0" width="343" height="125"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Nop-pL-Icx" customClass="AppBannerView" customModule="AltStore" customModuleProvider="target">
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Nop-pL-Icx" customClass="AppBannerView" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="343" height="50"/>
<constraints>
<constraint firstAttribute="height" constant="88" id="EPP-7O-1Ad"/>
@@ -39,7 +39,7 @@
<stackView opaque="NO" contentMode="scaleToFill" alignment="firstBaseline" translatesAutoresizingMaskIntoConstraints="NO" id="RSR-5W-7tt" userLabel="Release Notes">
<rect key="frame" x="0.0" y="50" width="343" height="75"/>
<subviews>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" text="Version Notes" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="rNs-2O-k3V" customClass="CollapsingTextView" customModule="AltStore" customModuleProvider="target">
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" text="Version Notes" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="rNs-2O-k3V" customClass="CollapsingMarkdownView" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="15" y="0.0" width="313" height="26"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
@@ -91,7 +91,7 @@
<color red="1" green="1" blue="1" alpha="0.30000001192092896" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<color red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@@ -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
}
}