From 4668f8499bbceea8fced695ee482b5998d0b602c Mon Sep 17 00:00:00 2001 From: mahee96 <47920326+mahee96@users.noreply.github.com> Date: Thu, 27 Feb 2025 05:22:11 +0530 Subject: [PATCH 1/6] - CI: fix release notes updater for invalid revision as input --- update_release_notes.py | 46 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/update_release_notes.py b/update_release_notes.py index 11a84d0a..7bc3c615 100644 --- a/update_release_notes.py +++ b/update_release_notes.py @@ -82,7 +82,7 @@ def format_commit_message(msg): msg_clean = msg_clean[1:].strip() # remove leading '-' and spaces return f"- {msg_clean}" -def generate_release_notes(last_successful, tag, branch): +# def generate_release_notes(last_successful, tag, branch): """Generate release notes for the given tag.""" current_commit = get_head_commit() messages = get_commit_messages(last_successful, current_commit) @@ -117,6 +117,50 @@ def generate_release_notes(last_successful, tag, branch): return new_section +def generate_release_notes(last_successful, tag, branch): + """Generate release notes for the given tag.""" + current_commit = get_head_commit() + try: + # Try to get commit messages using the provided last_successful commit + messages = get_commit_messages(last_successful, current_commit) + except subprocess.CalledProcessError: + # If the range is invalid (e.g. force push made last_successful obsolete), + # fall back to using the last 10 commits in the current branch. + print("\nInvalid revision range error, using last 10 commits as fallback.\n") + fallback_commit = run_command("git rev-parse HEAD~5") + messages = get_commit_messages(fallback_commit, current_commit) + last_successful = fallback_commit + + # Start with the tag header + new_section = f"{TAG_MARKER} {tag}\n" + + # What's Changed section (always present) + new_section += f"{HEADER_MARKER} What's Changed\n" + + if not messages or last_successful == current_commit: + new_section += "- Nothing...\n" + else: + for msg in messages: + new_section += f"{format_commit_message(msg)}\n" + + # New Contributors section (only if there are new contributors) + all_previous_authors = get_authors_in_range(f"{branch}") + recent_authors = get_authors_in_range(f"{last_successful}..{current_commit}") + new_contributors = recent_authors - all_previous_authors + + if new_contributors: + new_section += f"\n{HEADER_MARKER} New Contributors\n" + for author in sorted(new_contributors): + new_section += f"- {format_contributor(author)} made their first contribution\n" + + # Full Changelog section (only if there are changes) + if messages and last_successful != current_commit: + repo_url = get_repo_url() + changelog_link = f"{repo_url}/compare/{last_successful}...{current_commit}" + new_section += f"\n{HEADER_MARKER} Full Changelog: [{last_successful[:8]}...{current_commit[:8]}]({changelog_link})\n" + + return new_section + def update_release_md(existing_content, new_section, tag): """ Update input based on rules: From b316e84f0debd944ff7b2d37b7f4918fabf4df4b Mon Sep 17 00:00:00 2001 From: mahee96 <47920326+mahee96@users.noreply.github.com> Date: Thu, 27 Feb 2025 05:36:41 +0530 Subject: [PATCH 2/6] - CI: serialize on whole workflow instead of individual jobs --- .../workflows/reusable-sidestore-build.yml | 26 ++++++++++++------- ...ore-serialize.yml => sidestore-shared.yml} | 13 +++------- 2 files changed, 20 insertions(+), 19 deletions(-) rename .github/workflows/{sidestore-serialize.yml => sidestore-shared.yml} (51%) diff --git a/.github/workflows/reusable-sidestore-build.yml b/.github/workflows/reusable-sidestore-build.yml index 073b1105..eb81fd69 100644 --- a/.github/workflows/reusable-sidestore-build.yml +++ b/.github/workflows/reusable-sidestore-build.yml @@ -44,43 +44,49 @@ on: BUILD_LOG_ZIP_PASSWORD: required: false + +# since build cache, test-build cache, test-run cache are involved, out of order exec if serialization is on individual jobs will wreak all sorts of havoc +# so we serialize on the entire workflow +concurrency: + group: serialize-workflow + jobs: - serialize: - uses: ./.github/workflows/sidestore-serialize.yml + shared: + uses: ./.github/workflows/sidestore-shared.yml secrets: inherit build: - needs: serialize + needs: shared uses: ./.github/workflows/sidestore-build.yml with: is_beta: ${{ inputs.is_beta }} is_shared_build_num: ${{ inputs.is_shared_build_num }} release_tag: ${{ inputs.release_tag }} - short_commit: ${{ needs.serialize.outputs.short-commit }} + short_commit: ${{ needs.shared.outputs.short-commit }} bundle_id: ${{ inputs.bundle_id }} bundle_id_suffix: ${{ inputs.bundle_id_suffix }} secrets: inherit tests-build: if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_BUILD == '1' }} - needs: serialize + needs: shared uses: ./.github/workflows/sidestore-tests-build.yml with: release_tag: ${{ inputs.release_tag }} - short_commit: ${{ needs.serialize.outputs.short-commit }} + short_commit: ${{ needs.shared.outputs.short-commit }} secrets: inherit tests-run: if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }} - needs: [serialize, tests-build] + needs: [shared, tests-build] uses: ./.github/workflows/sidestore-tests-run.yml with: release_tag: ${{ inputs.release_tag }} - short_commit: ${{ needs.serialize.outputs.short-commit }} + short_commit: ${{ needs.shared.outputs.short-commit }} secrets: inherit deploy: - needs: [serialize, build, tests-build, tests-run] # Keep tests-run in needs + needs: [shared, build, tests-build, tests-run] # Keep tests-run in needs if: ${{ always() && (needs.tests-run.result == 'skipped' || needs.tests-run.result == 'success') }} uses: ./.github/workflows/sidestore-deploy.yml with: @@ -91,7 +97,7 @@ jobs: upstream_tag: ${{ inputs.upstream_tag }} upstream_name: ${{ inputs.upstream_name }} version: ${{ needs.build.outputs.version }} - short_commit: ${{ needs.serialize.outputs.short-commit }} + short_commit: ${{ needs.shared.outputs.short-commit }} release_channel: ${{ needs.build.outputs.release-channel }} marketing_version: ${{ needs.build.outputs.marketing-version }} bundle_id: ${{ inputs.bundle_id }} diff --git a/.github/workflows/sidestore-serialize.yml b/.github/workflows/sidestore-shared.yml similarity index 51% rename from .github/workflows/sidestore-serialize.yml rename to .github/workflows/sidestore-shared.yml index b5df50a7..1f9cc79e 100644 --- a/.github/workflows/sidestore-serialize.yml +++ b/.github/workflows/sidestore-shared.yml @@ -1,23 +1,18 @@ -name: SideStore Serialize +name: SideStore Shared on: workflow_call: outputs: short-commit: - value: ${{ jobs.serialize.outputs.short-commit }} + value: ${{ jobs.shared.outputs.short-commit }} jobs: - serialize: - name: Wait for other jobs - # since build cache, test-build cache, test-run cache are involved, out of order exec if serialization is on individual jobs will wreak all sorts of havoc - # so we serialize on the entire workflow - concurrency: - group: serialize-workflow + shared: + name: Shared Steps strategy: fail-fast: false runs-on: 'macos-15' steps: - - run: echo "No other contending jobs are running now..." - name: Set short commit hash id: commit-id run: | From 2bea980d1f18cab08f160f82838ca26126ee848c Mon Sep 17 00:00:00 2001 From: mahee96 <47920326+mahee96@users.noreply.github.com> Date: Thu, 27 Feb 2025 23:39:03 +0530 Subject: [PATCH 3/6] - Feature: Added markdown rendering for in-app update description field --- AltStore.xcodeproj/project.pbxproj | 43 +++ .../App Detail/AppContentViewController.swift | 100 +++++-- AltStore/Base.lproj/Main.storyboard | 2 +- AltStore/My Apps/MyAppsViewController.swift | 17 +- .../My Apps/UpdateCollectionViewCell.swift | 11 +- AltStore/My Apps/UpdateCollectionViewCell.xib | 12 +- .../Views/UIKit/CollapsingMarkdownView.swift | 267 ++++++++++++++++++ 7 files changed, 418 insertions(+), 34 deletions(-) create mode 100644 SideStore/Views/UIKit/CollapsingMarkdownView.swift 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..10fd0993 100644 --- a/AltStore/App Detail/AppContentViewController.swift +++ b/AltStore/App Detail/AppContentViewController.swift @@ -44,7 +44,8 @@ final class AppContentViewController: UITableViewController @IBOutlet private var subtitleLabel: UILabel! @IBOutlet private var descriptionTextView: CollapsingTextView! - @IBOutlet private var versionDescriptionTextView: CollapsingTextView! +// @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! @@ -54,9 +55,39 @@ final class AppContentViewController: UITableViewController @IBOutlet private(set) var appDetailCollectionViewController: AppDetailCollectionViewController! @IBOutlet private var appDetailCollectionViewHeightConstraint: NSLayoutConstraint! +// +// override func viewDidLoad() +// { +// super.viewDidLoad() +// +// self.tableView.contentInset.bottom = 20 +// +// self.subtitleLabel.text = self.app.subtitle +// self.descriptionTextView.text = self.app.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.versionLabel.text = nil +// self.versionDateLabel.text = nil +// self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: 0) +// } +// +// self.descriptionTextView.maximumNumberOfLines = 5 +// self.descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) +// +// self.versionDescriptionTextView.maximumNumberOfLines = 3 +// self.versionDescriptionTextView.toggleButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) +// } - override func viewDidLoad() - { + override func viewDidLoad() { super.viewDidLoad() self.tableView.contentInset.bottom = 20 @@ -64,26 +95,32 @@ final class AppContentViewController: UITableViewController self.subtitleLabel.text = self.app.subtitle self.descriptionTextView.text = self.app.localizedDescription - 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) } + // Set maximum number of lines (no extra target-action needed) self.descriptionTextView.maximumNumberOfLines = 5 - self.descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) - self.versionDescriptionTextView.maximumNumberOfLines = 3 - self.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) + + // Instead of adding another target for toggle, set the didToggleCollapse callback: + self.descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) + + self.versionDescriptionTextView.didToggleCollapse = { [weak self] in + guard let self = self else { return } + UIView.animate(withDuration: 0.25) { + self.tableView.beginUpdates() + self.tableView.endUpdates() + } + } } override func viewDidLayoutSubviews() @@ -162,14 +199,37 @@ 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.moreButton: + indexPath = IndexPath(row: Row.description.rawValue, section: 0) + // Toggle the state for the text view + self.descriptionTextView.isCollapsed.toggle() + + case self.versionDescriptionTextView.toggleButton: + indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0) + // Toggle the state for the markdown view + self.versionDescriptionTextView.isCollapsed.toggle() + default: return } - // Disable animations to prevent some potentially strange ones. - UIView.performWithoutAnimation { - self.tableView.reloadRows(at: [indexPath], with: .none) + // First apply the new state to the views + switch Row.allCases[indexPath.row] { + case .description: + self.descriptionTextView.setNeedsLayout() + self.descriptionTextView.layoutIfNeeded() + + case .versionDescription: + self.versionDescriptionTextView.setNeedsLayout() + self.versionDescriptionTextView.layoutIfNeeded() + + default: break + } + + // Then reload the row with animation + UIView.animate(withDuration: 0.25) { + // Force table view to recalculate height + self.tableView.beginUpdates() + self.tableView.endUpdates() } } } diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index 2b60ee1d..5962d35f 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -353,7 +353,7 @@ - + diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index 2cdb8df5..7906e71a 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -235,7 +235,7 @@ private extension MyAppsViewController cell.layoutMargins.right = self.view.layoutMargins.right cell.tintColor = app.tintColor ?? .altPrimary - cell.versionDescriptionTextView.text = latestSupportedVersion.localizedDescription + cell.versionDescriptionTextView.text = latestSupportedVersion.localizedDescription ?? "nil" cell.bannerView.iconImageView.image = nil cell.bannerView.iconImageView.isIndicatingActivity = true @@ -281,7 +281,7 @@ 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() @@ -724,22 +724,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..4dce613e 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! 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..c1ce6d46 --- /dev/null +++ b/SideStore/Views/UIKit/CollapsingMarkdownView.swift @@ -0,0 +1,267 @@ +// +// 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) + } + + static var enabledElements: MarkdownParser.EnabledElements { + [ + .header, + .list, + .quote, + .code, + .link, + .bold, + .italic, + ] + } + + var markdownParser: MarkdownParser { + MarkdownParser(font: Self.Fonts.body) + } +} + +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.updateToggleButtonTitle() + self.updateCollapsedState() + } + } + + var maximumNumberOfLines = 3 { + didSet { + self.updateCollapsedState() + self.setNeedsLayout() + } + } + + var text: String = "" { + didSet { + self.updateMarkdownContent() + self.shouldResetLayout = true + self.setNeedsLayout() + } + } + + var lineSpacing: Double = 2 { + didSet { + self.shouldResetLayout = true + self.setNeedsLayout() + } + } + + let toggleButton = UIButton(type: .system) + + private let textView = UITextView() + private let markdownParser = MarkdownManager().markdownParser + + private var shouldResetLayout: Bool = false + private var previousSize: CGSize? + + // 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 updateCollapsedState() { + // Update the button title + let title = isCollapsed ? NSLocalizedString("More", comment: "") : NSLocalizedString("Less", comment: "") + toggleButton.setTitle(title, for: .normal) + + // Update text view constraints + if isCollapsed { + textView.textContainer.maximumNumberOfLines = maximumNumberOfLines + + // Create exclusion path for button + let buttonSize = toggleButton.sizeThatFits(CGSize(width: 1000, height: 1000)) + let buttonY = (textView.font?.lineHeight ?? 0) * CGFloat(maximumNumberOfLines - 1) + + let exclusionFrame = CGRect( + x: bounds.width - buttonSize.width - 5, // Add some padding + y: buttonY, + width: buttonSize.width + 10, // Add padding around button + height: (textView.font?.lineHeight ?? 0) + 5 + ) + + textView.textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)] + } else { + textView.textContainer.maximumNumberOfLines = 0 + textView.textContainer.exclusionPaths = [] + } + + // Force layout update + textView.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 instead of more button + toggleButton.addTarget(self, action: #selector(toggleCollapsed(_:)), for: .primaryActionTriggered) + addSubview(toggleButton) + + // Update the button title based on current state + updateToggleButtonTitle() + + 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 + } + + // Make sure this is called properly + private func updateToggleButtonTitle() { + let title = isCollapsed ? NSLocalizedString("More", comment: "") : NSLocalizedString("Less", comment: "") + toggleButton.setTitle(title, for: .normal) + } + + + // MARK: - Layout + override func layoutSubviews() { + super.layoutSubviews() + + textView.frame = bounds + + // Position toggle button + let buttonSize = toggleButton.sizeThatFits(CGSize(width: 1000, height: 1000)) + + if isCollapsed { + let buttonY = (textView.font?.lineHeight ?? 0) * CGFloat(maximumNumberOfLines - 1) + toggleButton.frame = CGRect( + x: bounds.width - buttonSize.width, + y: buttonY, + width: buttonSize.width, + height: textView.font?.lineHeight ?? 0 + ) + } else { + // Position at the end of content when expanded + let textHeight = textView.sizeThatFits(bounds.size).height + let lineHeight = textView.font?.lineHeight ?? 0 + toggleButton.frame = CGRect( + x: bounds.width - buttonSize.width, + y: textHeight - lineHeight, + width: buttonSize.width, + height: lineHeight + ) + } + } + + @objc private func toggleCollapsed(_ sender: UIButton) { + isCollapsed.toggle() + updateToggleButtonTitle() + // Notify any observer that a toggle occurred + didToggleCollapse?() + } + + override var intrinsicContentSize: CGSize { + if isCollapsed { + guard let font = textView.font else { return super.intrinsicContentSize } + let height = font.lineHeight * CGFloat(maximumNumberOfLines) + lineSpacing * CGFloat(maximumNumberOfLines - 1) + return CGSize(width: UIView.noIntrinsicMetric, height: height) + } else { + // When expanded, use the full content size of the text view + let size = textView.sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude)) + return CGSize(width: UIView.noIntrinsicMetric, height: size.height) + } + } + + // 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 + } + +} + +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 + } +} From cfaf79f8788cff9a0a519af1b7d73cbe56371a21 Mon Sep 17 00:00:00 2001 From: mahee96 <47920326+mahee96@users.noreply.github.com> Date: Fri, 28 Feb 2025 01:02:41 +0530 Subject: [PATCH 4/6] - Fixes: disabled animations for the CollapsingMarkdownView when expanding/collapsing due to visual glitches --- .../Views/UIKit/CollapsingMarkdownView.swift | 140 ++++++++++++------ 1 file changed, 91 insertions(+), 49 deletions(-) diff --git a/SideStore/Views/UIKit/CollapsingMarkdownView.swift b/SideStore/Views/UIKit/CollapsingMarkdownView.swift index c1ce6d46..248ee3ee 100644 --- a/SideStore/Views/UIKit/CollapsingMarkdownView.swift +++ b/SideStore/Views/UIKit/CollapsingMarkdownView.swift @@ -101,35 +101,56 @@ final class CollapsingMarkdownView: UIView { initialize() } + private var needsCollapsing = false + private func checkIfNeedsCollapsing() { + let textSize = textView.sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude)) + let lineHeight = textView.font?.lineHeight ?? 0 + let actualLines = textSize.height / lineHeight + + needsCollapsing = actualLines > CGFloat(maximumNumberOfLines) + + // Hide toggle button if no collapsing needed + toggleButton.isHidden = !needsCollapsing + } + private func updateCollapsedState() { - // Update the button title - let title = isCollapsed ? NSLocalizedString("More", comment: "") : NSLocalizedString("Less", comment: "") - toggleButton.setTitle(title, for: .normal) - - // Update text view constraints - if isCollapsed { - textView.textContainer.maximumNumberOfLines = maximumNumberOfLines + // Disable animations for this update to prevent gradual rearrangement + UIView.performWithoutAnimation { + // Update the button title + let title = isCollapsed ? NSLocalizedString("More", comment: "") : NSLocalizedString("Less", comment: "") + toggleButton.setTitle(title, for: .normal) - // Create exclusion path for button - let buttonSize = toggleButton.sizeThatFits(CGSize(width: 1000, height: 1000)) - let buttonY = (textView.font?.lineHeight ?? 0) * CGFloat(maximumNumberOfLines - 1) + // Make sure toggle button is only visible when needed + toggleButton.isHidden = !needsCollapsing - let exclusionFrame = CGRect( - x: bounds.width - buttonSize.width - 5, // Add some padding - y: buttonY, - width: buttonSize.width + 10, // Add padding around button - height: (textView.font?.lineHeight ?? 0) + 5 - ) + // Update text view constraints + if isCollapsed && needsCollapsing { + textView.textContainer.maximumNumberOfLines = maximumNumberOfLines + + // Create exclusion path for button + let buttonSize = toggleButton.sizeThatFits(CGSize(width: 1000, height: 1000)) + let buttonY = (textView.font?.lineHeight ?? 0) * CGFloat(maximumNumberOfLines - 1) + + let exclusionFrame = CGRect( + x: bounds.width - buttonSize.width - 5, // Add some padding + y: buttonY, + width: buttonSize.width + 10, // Add padding around button + height: (textView.font?.lineHeight ?? 0) + 5 + ) + + textView.textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)] + } else { + textView.textContainer.maximumNumberOfLines = 0 + textView.textContainer.exclusionPaths = [] + } - textView.textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)] - } else { - textView.textContainer.maximumNumberOfLines = 0 - textView.textContainer.exclusionPaths = [] + // Force an immediate layout update + textView.layoutIfNeeded() + self.layoutIfNeeded() + + // Invalidate intrinsic content size to ensure proper sizing + self.invalidateIntrinsicContentSize() } - - // Force layout update - textView.layoutIfNeeded() - self.invalidateIntrinsicContentSize() } private func initialize() { @@ -182,40 +203,58 @@ final class CollapsingMarkdownView: UIView { toggleButton.setTitle(title, for: .normal) } - + // MARK: - Layout override func layoutSubviews() { super.layoutSubviews() - textView.frame = bounds - - // Position toggle button - let buttonSize = toggleButton.sizeThatFits(CGSize(width: 1000, height: 1000)) - - if isCollapsed { - let buttonY = (textView.font?.lineHeight ?? 0) * CGFloat(maximumNumberOfLines - 1) - toggleButton.frame = CGRect( - x: bounds.width - buttonSize.width, - y: buttonY, - width: buttonSize.width, - height: textView.font?.lineHeight ?? 0 - ) - } else { - // Position at the end of content when expanded - let textHeight = textView.sizeThatFits(bounds.size).height - let lineHeight = textView.font?.lineHeight ?? 0 - toggleButton.frame = CGRect( - x: bounds.width - buttonSize.width, - y: textHeight - lineHeight, - width: buttonSize.width, - height: lineHeight - ) + UIView.performWithoutAnimation { + textView.frame = bounds + + // Check if content needs collapsing when layout changes + if shouldResetLayout || previousSize?.width != bounds.width { + checkIfNeedsCollapsing() + shouldResetLayout = false + previousSize = bounds.size + } + + // Only position toggle button if it's needed + if !toggleButton.isHidden { + let buttonSize = toggleButton.sizeThatFits(CGSize(width: 1000, height: 1000)) + + if isCollapsed { + let buttonY = (textView.font?.lineHeight ?? 0) * CGFloat(maximumNumberOfLines - 1) + toggleButton.frame = CGRect( + x: bounds.width - buttonSize.width, + y: buttonY, + width: buttonSize.width, + height: textView.font?.lineHeight ?? 0 + ) + } else { + // Position at the end of content when expanded + let textHeight = textView.sizeThatFits(bounds.size).height + let lineHeight = textView.font?.lineHeight ?? 0 + toggleButton.frame = CGRect( + x: bounds.width - buttonSize.width, + y: textHeight - lineHeight, + width: buttonSize.width, + height: lineHeight + ) + } + } } } @objc private func toggleCollapsed(_ sender: UIButton) { + // Toggle the state instantly isCollapsed.toggle() - updateToggleButtonTitle() + + // Update the UI without animation + UIView.performWithoutAnimation { + updateToggleButtonTitle() + updateCollapsedState() + } + // Notify any observer that a toggle occurred didToggleCollapse?() } @@ -248,6 +287,9 @@ final class CollapsingMarkdownView: UIView { ) textView.attributedText = mutableAttributedString + + // Check if content needs collapsing after setting text + checkIfNeedsCollapsing() } } From 2197161d55d9307f23bba0e722e600f20c333f7b Mon Sep 17 00:00:00 2001 From: mahee96 <47920326+mahee96@users.noreply.github.com> Date: Fri, 28 Feb 2025 02:28:34 +0530 Subject: [PATCH 5/6] - Fix: CollapsingMarkdownView was not reloading properly during layoutSubviews phase --- AltStore/My Apps/MyAppsViewController.swift | 5 +---- .../My Apps/UpdateCollectionViewCell.swift | 20 +++++++++---------- .../Views/UIKit/CollapsingMarkdownView.swift | 6 +----- 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index 7906e71a..a7dee21c 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -282,11 +282,8 @@ private extension MyAppsViewController } 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 diff --git a/AltStore/My Apps/UpdateCollectionViewCell.swift b/AltStore/My Apps/UpdateCollectionViewCell.swift index 4dce613e..32b82770 100644 --- a/AltStore/My Apps/UpdateCollectionViewCell.swift +++ b/AltStore/My Apps/UpdateCollectionViewCell.swift @@ -92,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/SideStore/Views/UIKit/CollapsingMarkdownView.swift b/SideStore/Views/UIKit/CollapsingMarkdownView.swift index 248ee3ee..133f092c 100644 --- a/SideStore/Views/UIKit/CollapsingMarkdownView.swift +++ b/SideStore/Views/UIKit/CollapsingMarkdownView.swift @@ -64,14 +64,12 @@ final class CollapsingMarkdownView: UIView { var text: String = "" { didSet { self.updateMarkdownContent() - self.shouldResetLayout = true self.setNeedsLayout() } } var lineSpacing: Double = 2 { didSet { - self.shouldResetLayout = true self.setNeedsLayout() } } @@ -81,7 +79,6 @@ final class CollapsingMarkdownView: UIView { private let textView = UITextView() private let markdownParser = MarkdownManager().markdownParser - private var shouldResetLayout: Bool = false private var previousSize: CGSize? // MARK: - Initialization @@ -212,9 +209,8 @@ final class CollapsingMarkdownView: UIView { textView.frame = bounds // Check if content needs collapsing when layout changes - if shouldResetLayout || previousSize?.width != bounds.width { + if previousSize?.width != bounds.width { checkIfNeedsCollapsing() - shouldResetLayout = false previousSize = bounds.size } From c6703d66c163a505d51db29d353217a5cbf6da61 Mon Sep 17 00:00:00 2001 From: mahee96 <47920326+mahee96@users.noreply.github.com> Date: Fri, 28 Feb 2025 05:09:37 +0530 Subject: [PATCH 6/6] - Feature: Markdown view integration complete (if issues arrise can fix it asap) --- .../App Detail/AppContentViewController.swift | 81 ++------- AltStore/Base.lproj/Main.storyboard | 2 +- AltStore/Components/CollapsingTextView.swift | 53 +++--- AltStore/My Apps/MyAppsViewController.swift | 1 + .../Views/UIKit/CollapsingMarkdownView.swift | 168 +++++++++--------- 5 files changed, 116 insertions(+), 189 deletions(-) diff --git a/AltStore/App Detail/AppContentViewController.swift b/AltStore/App Detail/AppContentViewController.swift index 10fd0993..8ba1a195 100644 --- a/AltStore/App Detail/AppContentViewController.swift +++ b/AltStore/App Detail/AppContentViewController.swift @@ -43,7 +43,8 @@ final class AppContentViewController: UITableViewController }() @IBOutlet private var subtitleLabel: UILabel! - @IBOutlet private var descriptionTextView: 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! @@ -55,37 +56,6 @@ final class AppContentViewController: UITableViewController @IBOutlet private(set) var appDetailCollectionViewController: AppDetailCollectionViewController! @IBOutlet private var appDetailCollectionViewHeightConstraint: NSLayoutConstraint! -// -// override func viewDidLoad() -// { -// super.viewDidLoad() -// -// self.tableView.contentInset.bottom = 20 -// -// self.subtitleLabel.text = self.app.subtitle -// self.descriptionTextView.text = self.app.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.versionLabel.text = nil -// self.versionDateLabel.text = nil -// self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: 0) -// } -// -// self.descriptionTextView.maximumNumberOfLines = 5 -// self.descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) -// -// self.versionDescriptionTextView.maximumNumberOfLines = 3 -// self.versionDescriptionTextView.toggleButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) -// } override func viewDidLoad() { super.viewDidLoad() @@ -93,7 +63,8 @@ final class AppContentViewController: UITableViewController 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 ?? "nil" @@ -107,20 +78,11 @@ final class AppContentViewController: UITableViewController self.sizeLabel.text = ByteCountFormatter.string(fromByteCount: 0, countStyle: .file) } - // Set maximum number of lines (no extra target-action needed) self.descriptionTextView.maximumNumberOfLines = 5 - self.versionDescriptionTextView.maximumNumberOfLines = 3 + self.versionDescriptionTextView.maximumNumberOfLines = 5 - // Instead of adding another target for toggle, set the didToggleCollapse callback: - self.descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) - - self.versionDescriptionTextView.didToggleCollapse = { [weak self] in - guard let self = self else { return } - UIView.animate(withDuration: 0.25) { - self.tableView.beginUpdates() - self.tableView.endUpdates() - } - } + 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() @@ -199,37 +161,18 @@ private extension AppContentViewController switch sender { - case self.descriptionTextView.moreButton: + case self.descriptionTextView.toggleButton: indexPath = IndexPath(row: Row.description.rawValue, section: 0) - // Toggle the state for the text view - self.descriptionTextView.isCollapsed.toggle() case self.versionDescriptionTextView.toggleButton: indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0) - // Toggle the state for the markdown view - self.versionDescriptionTextView.isCollapsed.toggle() - + default: return } - // First apply the new state to the views - switch Row.allCases[indexPath.row] { - case .description: - self.descriptionTextView.setNeedsLayout() - self.descriptionTextView.layoutIfNeeded() - - case .versionDescription: - self.versionDescriptionTextView.setNeedsLayout() - self.versionDescriptionTextView.layoutIfNeeded() - - default: break - } - - // Then reload the row with animation - UIView.animate(withDuration: 0.25) { - // Force table view to recalculate height - self.tableView.beginUpdates() - self.tableView.endUpdates() + // Disable animations to prevent some potentially strange ones. + UIView.performWithoutAnimation { + self.tableView.reloadRows(at: [indexPath], with: .none) } } } diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index 5962d35f..d33cf363 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -287,7 +287,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 a7dee21c..b4a0851b 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -235,6 +235,7 @@ private extension MyAppsViewController cell.layoutMargins.right = self.view.layoutMargins.right cell.tintColor = app.tintColor ?? .altPrimary + cell.versionDescriptionTextView.maximumNumberOfLines = 2 cell.versionDescriptionTextView.text = latestSupportedVersion.localizedDescription ?? "nil" cell.bannerView.iconImageView.image = nil diff --git a/SideStore/Views/UIKit/CollapsingMarkdownView.swift b/SideStore/Views/UIKit/CollapsingMarkdownView.swift index 133f092c..2c4a2a9f 100644 --- a/SideStore/Views/UIKit/CollapsingMarkdownView.swift +++ b/SideStore/Views/UIKit/CollapsingMarkdownView.swift @@ -23,6 +23,15 @@ struct MarkdownManager 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, @@ -36,26 +45,27 @@ struct MarkdownManager } var markdownParser: MarkdownParser { - MarkdownParser(font: Self.Fonts.body) + 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.updateToggleButtonTitle() self.updateCollapsedState() } } var maximumNumberOfLines = 3 { didSet { + self.checkIfNeedsCollapsing() self.updateCollapsedState() self.setNeedsLayout() } @@ -75,11 +85,13 @@ final class CollapsingMarkdownView: UIView { } 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 @@ -98,54 +110,46 @@ final class CollapsingMarkdownView: UIView { initialize() } - private var needsCollapsing = false 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 = textView.font?.lineHeight ?? 0 - let actualLines = textSize.height / lineHeight + let lineHeight = font.lineHeight - needsCollapsing = actualLines > CGFloat(maximumNumberOfLines) + // Safely calculate actual line count + actualLineCount = max(1, Int(ceil(textSize.height / lineHeight))) - // Hide toggle button if no collapsing needed + // 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 to prevent gradual rearrangement + // 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) - // Make sure toggle button is only visible when needed - toggleButton.isHidden = !needsCollapsing - - // Update text view constraints + // Set max lines based on collapsed state if isCollapsed && needsCollapsing { textView.textContainer.maximumNumberOfLines = maximumNumberOfLines - - // Create exclusion path for button - let buttonSize = toggleButton.sizeThatFits(CGSize(width: 1000, height: 1000)) - let buttonY = (textView.font?.lineHeight ?? 0) * CGFloat(maximumNumberOfLines - 1) - - let exclusionFrame = CGRect( - x: bounds.width - buttonSize.width - 5, // Add some padding - y: buttonY, - width: buttonSize.width + 10, // Add padding around button - height: (textView.font?.lineHeight ?? 0) + 5 - ) - - textView.textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)] } else { textView.textContainer.maximumNumberOfLines = 0 - textView.textContainer.exclusionPaths = [] } - // Force an immediate layout update + // Button is only visible if content needs collapsing + toggleButton.isHidden = !needsCollapsing + + // Force layout updates textView.layoutIfNeeded() self.layoutIfNeeded() - - // Invalidate intrinsic content size to ensure proper sizing self.invalidateIntrinsicContentSize() } } @@ -172,13 +176,10 @@ final class CollapsingMarkdownView: UIView { // Add subviews addSubview(textView) - // Configure toggle button instead of more button + // Configure toggle button toggleButton.addTarget(self, action: #selector(toggleCollapsed(_:)), for: .primaryActionTriggered) addSubview(toggleButton) - // Update the button title based on current state - updateToggleButtonTitle() - setNeedsLayout() } @@ -192,78 +193,73 @@ final class CollapsingMarkdownView: UIView { 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 } - // Make sure this is called properly - private func updateToggleButtonTitle() { - let title = isCollapsed ? NSLocalizedString("More", comment: "") : NSLocalizedString("Less", comment: "") - toggleButton.setTitle(title, for: .normal) - } - - // MARK: - Layout override func layoutSubviews() { super.layoutSubviews() UIView.performWithoutAnimation { - textView.frame = bounds + // Calculate button height (for spacing) + let buttonHeight = toggleButton.sizeThatFits(CGSize(width: 1000, height: 1000)).height - // Check if content needs collapsing when layout changes + // 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 } - // Only position toggle button if it's needed - if !toggleButton.isHidden { - let buttonSize = toggleButton.sizeThatFits(CGSize(width: 1000, height: 1000)) - - if isCollapsed { - let buttonY = (textView.font?.lineHeight ?? 0) * CGFloat(maximumNumberOfLines - 1) - toggleButton.frame = CGRect( - x: bounds.width - buttonSize.width, - y: buttonY, - width: buttonSize.width, - height: textView.font?.lineHeight ?? 0 - ) - } else { - // Position at the end of content when expanded - let textHeight = textView.sizeThatFits(bounds.size).height - let lineHeight = textView.font?.lineHeight ?? 0 - toggleButton.frame = CGRect( - x: bounds.width - buttonSize.width, - y: textHeight - lineHeight, - width: buttonSize.width, - height: lineHeight - ) - } - } + // 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) { - // Toggle the state instantly isCollapsed.toggle() - - // Update the UI without animation - UIView.performWithoutAnimation { - updateToggleButtonTitle() - updateCollapsedState() - } - - // Notify any observer that a toggle occurred didToggleCollapse?() } override var intrinsicContentSize: CGSize { - if isCollapsed { - guard let font = textView.font else { return super.intrinsicContentSize } - let height = font.lineHeight * CGFloat(maximumNumberOfLines) + lineSpacing * CGFloat(maximumNumberOfLines - 1) - return CGSize(width: UIView.noIntrinsicMetric, height: height) + 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, use the full content size of the text view - let size = textView.sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude)) - return CGSize(width: UIView.noIntrinsicMetric, height: size.height) + // 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) } } @@ -286,8 +282,8 @@ final class CollapsingMarkdownView: UIView { // Check if content needs collapsing after setting text checkIfNeedsCollapsing() + updateCollapsedState() } - } extension CollapsingMarkdownView: UITextViewDelegate {