From 654f73f4ee0a2ef02d8cc861b4e6843492da167f Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Tue, 4 Apr 2023 15:41:44 -0500 Subject: [PATCH] =?UTF-8?q?Shows=20detailed=20source=20=E2=80=9CAbout?= =?UTF-8?q?=E2=80=9D=20page=20when=20adding=203rd-party=20sources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows users to preview sources before adding them to their AltStore. --- AltStore.xcodeproj/project.pbxproj | 28 + .../HeaderContentViewController.swift | 568 ++++++++++++++++++ AltStore/Components/PillButton.swift | 20 + AltStore/Components/VibrantButton.swift | 150 +++++ .../SourceDetailContentViewController.swift | 341 +++++++++++ .../Sources/SourceDetailViewController.swift | 108 ++++ .../Sources/SourceDetailsComponents.swift | 89 +++ AltStore/Sources/SourceHeaderView.swift | 106 ++++ AltStore/Sources/SourceHeaderView.xib | 206 +++++++ AltStore/Sources/Sources.storyboard | 63 ++ AltStore/Sources/SourcesViewController.swift | 54 +- AltStoreCore/Model/NewsItem.swift | 16 + 12 files changed, 1718 insertions(+), 31 deletions(-) create mode 100644 AltStore/Components/HeaderContentViewController.swift create mode 100644 AltStore/Components/VibrantButton.swift create mode 100644 AltStore/Sources/SourceDetailContentViewController.swift create mode 100644 AltStore/Sources/SourceDetailViewController.swift create mode 100644 AltStore/Sources/SourceDetailsComponents.swift create mode 100644 AltStore/Sources/SourceHeaderView.swift create mode 100644 AltStore/Sources/SourceHeaderView.xib diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index ae118dd8..025c0cff 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -355,6 +355,7 @@ D570841A2924680D00D42D34 /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; }; D561B2EB28EF5A4F006752E4 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = D561B2EA28EF5A4F006752E4 /* AltSign-Dynamic */; }; D561B2ED28EF5A4F006752E4 /* AltSign-Dynamic in Embed Frameworks */ = {isa = PBXBuildFile; productRef = D561B2EA28EF5A4F006752E4 /* AltSign-Dynamic */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + D57968CB29CB99EF00539069 /* VibrantButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D57968CA29CB99EF00539069 /* VibrantButton.swift */; }; D57DF638271E32F000677701 /* PatchApp.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D57DF637271E32F000677701 /* PatchApp.storyboard */; }; D57DF63F271E51E400677701 /* ALTAppPatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = D57DF63E271E51E400677701 /* ALTAppPatcher.m */; }; D57F2C9126E0070200B9FA39 /* EnableJITOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D57F2C9026E0070200B9FA39 /* EnableJITOperation.swift */; }; @@ -363,12 +364,18 @@ D586D39B28EF58B0000E101F /* AltTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D586D39A28EF58B0000E101F /* AltTests.swift */; }; D58916FE28C7C55C00E39C8B /* LoggedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58916FD28C7C55C00E39C8B /* LoggedError.swift */; }; D58D5F2E26DFE68E00E55E38 /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = D58D5F2D26DFE68E00E55E38 /* LaunchAtLogin */; }; + D59162AB29BA60A9005CBF47 /* SourceHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D59162AA29BA60A9005CBF47 /* SourceHeaderView.swift */; }; + D59162AD29BA616A005CBF47 /* SourceHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D59162AC29BA616A005CBF47 /* SourceHeaderView.xib */; }; + D5935AED29C39DE300C157EF /* SourceDetailsComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5935AEC29C39DE300C157EF /* SourceDetailsComponents.swift */; }; D5935AEF29C3B23600C157EF /* Sources.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D5935AEE29C3B23600C157EF /* Sources.storyboard */; }; D593F1942717749A006E82DE /* PatchAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D593F1932717749A006E82DE /* PatchAppOperation.swift */; }; + D5A0537329B91DB400997551 /* SourceDetailContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A0537229B91DB400997551 /* SourceDetailContentViewController.swift */; }; D5A2193429B14F94002229FC /* DeprecatedAPIs.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A2193329B14F94002229FC /* DeprecatedAPIs.swift */; }; D5ACE84528E3B8450021CAB9 /* ClearAppCacheOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */; }; D5CA0C4B280E141900469595 /* ManagedPatron.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CA0C4A280E141900469595 /* ManagedPatron.swift */; }; D5CA0C4E280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = D5CA0C4D280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel */; }; + D5CD805D29CA2C1E00E591B0 /* HeaderContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CD805C29CA2C1E00E591B0 /* HeaderContentViewController.swift */; }; + D5CD805F29CA755E00E591B0 /* SourceDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CD805E29CA755E00E591B0 /* SourceDetailViewController.swift */; }; D5DAE0942804B0B80034D8D4 /* ScreenshotProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */; }; D5DAE0962804DF430034D8D4 /* UpdatePatronsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */; }; D5DB145A28F9DC5A00A8F606 /* ALTLocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DB145828F9DC1000A8F606 /* ALTLocalizedError.swift */; }; @@ -900,6 +907,7 @@ D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorLogTableViewCell.swift; sourceTree = ""; }; D55E163528776CB000A627A1 /* ComplicationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationView.swift; sourceTree = ""; }; D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperatingSystemVersion+Comparable.swift"; sourceTree = ""; }; + D57968CA29CB99EF00539069 /* VibrantButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VibrantButton.swift; sourceTree = ""; }; D57DF637271E32F000677701 /* PatchApp.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = PatchApp.storyboard; sourceTree = ""; }; D57DF63D271E51E400677701 /* ALTAppPatcher.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ALTAppPatcher.h; sourceTree = ""; }; D57DF63E271E51E400677701 /* ALTAppPatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ALTAppPatcher.m; sourceTree = ""; }; @@ -909,13 +917,19 @@ D586D39828EF58B0000E101F /* AltTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AltTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D586D39A28EF58B0000E101F /* AltTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AltTests.swift; sourceTree = ""; }; D58916FD28C7C55C00E39C8B /* LoggedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggedError.swift; sourceTree = ""; }; + D59162AA29BA60A9005CBF47 /* SourceHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceHeaderView.swift; sourceTree = ""; }; + D59162AC29BA616A005CBF47 /* SourceHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SourceHeaderView.xib; sourceTree = ""; }; + D5935AEC29C39DE300C157EF /* SourceDetailsComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceDetailsComponents.swift; sourceTree = ""; }; D5935AEE29C3B23600C157EF /* Sources.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Sources.storyboard; sourceTree = ""; }; D593F1932717749A006E82DE /* PatchAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchAppOperation.swift; sourceTree = ""; }; + D5A0537229B91DB400997551 /* SourceDetailContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceDetailContentViewController.swift; sourceTree = ""; }; D5A2193329B14F94002229FC /* DeprecatedAPIs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeprecatedAPIs.swift; sourceTree = ""; }; D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearAppCacheOperation.swift; sourceTree = ""; }; D5CA0C4A280E141900469595 /* ManagedPatron.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedPatron.swift; sourceTree = ""; }; D5CA0C4C280E242500469595 /* AltStore 10.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 10.xcdatamodel"; sourceTree = ""; }; D5CA0C4D280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = AltStore9ToAltStore10.xcmappingmodel; sourceTree = ""; }; + D5CD805C29CA2C1E00E591B0 /* HeaderContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderContentViewController.swift; sourceTree = ""; }; + D5CD805E29CA755E00E591B0 /* SourceDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceDetailViewController.swift; sourceTree = ""; }; D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotProcessor.swift; sourceTree = ""; }; D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePatronsOperation.swift; sourceTree = ""; }; D5DB145828F9DC1000A8F606 /* ALTLocalizedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ALTLocalizedError.swift; sourceTree = ""; }; @@ -1605,6 +1619,11 @@ children = ( D5935AEE29C3B23600C157EF /* Sources.storyboard */, BFC84A4C2421A19100853474 /* SourcesViewController.swift */, + D5CD805E29CA755E00E591B0 /* SourceDetailViewController.swift */, + D5A0537229B91DB400997551 /* SourceDetailContentViewController.swift */, + D5935AEC29C39DE300C157EF /* SourceDetailsComponents.swift */, + D59162AA29BA60A9005CBF47 /* SourceHeaderView.swift */, + D59162AC29BA616A005CBF47 /* SourceHeaderView.xib */, ); path = Sources; sourceTree = ""; @@ -1723,12 +1742,14 @@ BF770E6622BD57C3002A40FE /* BackgroundTaskManager.swift */, BF9ABA4A22DD137F008935CF /* NavigationBar.swift */, BF9ABA4C22DD16DE008935CF /* PillButton.swift */, + D57968CA29CB99EF00539069 /* VibrantButton.swift */, BF18B0F022E25DF9005C4CF5 /* ToastView.swift */, BF3D649E22E7B24C00E9056B /* CollapsingTextView.swift */, BF2901302318F7A800D88A45 /* AppBannerView.swift */, BF29012E2318F6B100D88A45 /* AppBannerView.xib */, BF6C8FAD2429597900125131 /* AppBannerCollectionViewCell.swift */, BF6C8FAF2429599900125131 /* TextCollectionReusableView.swift */, + D5CD805C29CA2C1E00E591B0 /* HeaderContentViewController.swift */, ); path = Components; sourceTree = ""; @@ -2343,6 +2364,7 @@ files = ( BFC57A702416FC7600EB891E /* InstalledAppsCollectionHeaderView.xib in Resources */, BFB4323F22DE852000B7F8BC /* UpdateCollectionViewCell.xib in Resources */, + D59162AD29BA616A005CBF47 /* SourceHeaderView.xib in Resources */, BFE60738231ADF49002B0E8E /* Settings.storyboard in Resources */, D57DF638271E32F000677701 /* PatchApp.storyboard in Resources */, BFD2477A2284B9A700981D42 /* LaunchScreen.storyboard in Resources */, @@ -2665,14 +2687,19 @@ D5E1E7C128077DE90016FC96 /* FetchTrustedSourcesOperation.swift in Sources */, BFE338DF22F0EADB002E24B9 /* FetchSourceOperation.swift in Sources */, D54DED1428CBC44B008B27A0 /* ErrorLogTableViewCell.swift in Sources */, + D5CD805F29CA755E00E591B0 /* SourceDetailViewController.swift in Sources */, + D57968CB29CB99EF00539069 /* VibrantButton.swift in Sources */, BFB6B21E231870160022A802 /* NewsViewController.swift in Sources */, BFC57A652416C72400EB891E /* DeactivateAppOperation.swift in Sources */, + D5CD805D29CA2C1E00E591B0 /* HeaderContentViewController.swift in Sources */, BF3BEFC124086A1E00DE7D55 /* RefreshAppOperation.swift in Sources */, BFE60740231AFD2A002B0E8E /* InsetGroupTableViewCell.swift in Sources */, BF0DCA662433BDF500E3A595 /* AnalyticsManager.swift in Sources */, BFCCB51A245E3401001853EA /* VerifyAppOperation.swift in Sources */, BFF0B6982322CAB8007A79E1 /* InstructionsViewController.swift in Sources */, BF9ABA4522DCFF43008935CF /* BrowseViewController.swift in Sources */, + D5935AED29C39DE300C157EF /* SourceDetailsComponents.swift in Sources */, + D5A0537329B91DB400997551 /* SourceDetailContentViewController.swift in Sources */, BF770E5422BC044E002A40FE /* OperationContexts.swift in Sources */, BFD2478C2284C4C300981D42 /* AppIconImageView.swift in Sources */, BF8F69C422E662D300049BA1 /* AppViewController.swift in Sources */, @@ -2712,6 +2739,7 @@ 19B9B7452845E6DF0076EF69 /* SelectTeamViewController.swift in Sources */, 99F87D0529D8B4E200B40039 /* minimuxer-helpers.swift in Sources */, 0E13E5862CC8F55900E9C0DF /* ProcessInfo+SideStore.swift in Sources */, + D59162AB29BA60A9005CBF47 /* SourceHeaderView.swift in Sources */, BF18B0F122E25DF9005C4CF5 /* ToastView.swift in Sources */, BF3D649F22E7B24C00E9056B /* CollapsingTextView.swift in Sources */, BF02419622F2199300129732 /* RefreshAttemptsViewController.swift in Sources */, diff --git a/AltStore/Components/HeaderContentViewController.swift b/AltStore/Components/HeaderContentViewController.swift new file mode 100644 index 00000000..91c691cf --- /dev/null +++ b/AltStore/Components/HeaderContentViewController.swift @@ -0,0 +1,568 @@ +// +// HeaderContentViewController.swift +// AltStore +// +// Created by Riley Testut on 3/10/23. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import UIKit + +import AltStoreCore +import Roxas + +import Nuke + +protocol ScrollableContentViewController: UIViewController +{ + var scrollView: UIScrollView { get } +} + +class HeaderContentViewController : UIViewController, + UIAdaptivePresentationControllerDelegate, + UIScrollViewDelegate +{ + var tintColor: UIColor? { + didSet { + guard self.isViewLoaded else { return } + + self.view.tintColor = self.tintColor + self.update() + } + } + + private(set) var headerView: Header! + private(set) var contentViewController: Content! + + private(set) var backButton: VibrantButton! + private(set) var backgroundImageView: UIImageView! + + private(set) var navigationBarNameLabel: UILabel! + private(set) var navigationBarIconView: UIImageView! + private(set) var navigationBarTitleView: UIStackView! + private(set) var navigationBarButton: PillButton! + + private var scrollView: UIScrollView! + private var headerScrollView: UIScrollView! + private var headerContainerView: UIView! + private var backgroundBlurView: UIVisualEffectView! + private var contentViewControllerShadowView: UIView! + + private var blurAnimator: UIViewPropertyAnimator? + private var navigationBarAnimator: UIViewPropertyAnimator? + private var contentSizeObservation: NSKeyValueObservation? + + private var _shouldResetLayout = false + private var _backgroundBlurEffect: UIBlurEffect? + private var _backgroundBlurTintColor: UIColor? + + private var isViewingHeader: Bool { + let isViewingHeader = (self.headerScrollView.contentOffset.x != self.headerScrollView.contentInset.left) + return isViewingHeader + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return _preferredStatusBarStyle + } + private var _preferredStatusBarStyle: UIStatusBarStyle = .default + + init() + { + super.init(nibName: nil, bundle: nil) + } + + deinit + { + self.blurAnimator?.stopAnimation(true) + self.navigationBarAnimator?.stopAnimation(true) + } + + required init?(coder: NSCoder) + { + super.init(coder: coder) + } + + func makeContentViewController() -> Content + { + fatalError() + } + + func makeHeaderView() -> Header + { + fatalError() + } + + override func viewDidLoad() + { + super.viewDidLoad() + + self.view.backgroundColor = .white + self.view.clipsToBounds = true + + self.navigationItem.largeTitleDisplayMode = .never + self.navigationController?.presentationController?.delegate = self + + + // Background + self.backgroundImageView = UIImageView(frame: .zero) + self.backgroundImageView.contentMode = .scaleAspectFill + self.view.addSubview(self.backgroundImageView) + + let blurEffect = UIBlurEffect(style: .regular) + self.backgroundBlurView = UIVisualEffectView(effect: blurEffect) + self.view.addSubview(self.backgroundBlurView, pinningEdgesWith: .zero) + + + // Header View + self.headerContainerView = UIView(frame: .zero) + self.view.addSubview(self.headerContainerView, pinningEdgesWith: .zero) + + self.headerScrollView = UIScrollView(frame: .zero) + self.headerScrollView.delegate = self + self.headerScrollView.isPagingEnabled = true + self.headerScrollView.clipsToBounds = false + self.headerScrollView.indicatorStyle = .white + self.headerScrollView.showsVerticalScrollIndicator = false + self.headerContainerView.addSubview(self.headerScrollView) + self.headerContainerView.addGestureRecognizer(self.headerScrollView.panGestureRecognizer) // Allow panning outside headerScrollView bounds. + + self.headerView = self.makeHeaderView() + self.headerScrollView.addSubview(self.headerView) + + let imageConfiguration = UIImage.SymbolConfiguration(weight: .semibold) + let image = UIImage(systemName: "chevron.backward", withConfiguration: imageConfiguration) + + self.backButton = VibrantButton(type: .system) + self.backButton.image = image + self.backButton.tintColor = self.tintColor + self.backButton.sizeToFit() + self.backButton.addTarget(self.navigationController, action: #selector(UINavigationController.popViewController(animated:)), for: .primaryActionTriggered) + self.view.addSubview(self.backButton) + + + // Content View Controller + self.contentViewController = self.makeContentViewController() + self.contentViewController.view.frame = self.view.bounds + self.contentViewController.view.layer.cornerRadius = 38 + self.contentViewController.view.layer.masksToBounds = true + + self.addChild(self.contentViewController) + self.view.addSubview(self.contentViewController.view) + self.contentViewController.didMove(toParent: self) + + self.contentViewControllerShadowView = UIView() + self.contentViewControllerShadowView.backgroundColor = .white + self.contentViewControllerShadowView.layer.cornerRadius = 38 + self.contentViewControllerShadowView.layer.shadowColor = UIColor.black.cgColor + self.contentViewControllerShadowView.layer.shadowOffset = CGSize(width: 0, height: -1) + self.contentViewControllerShadowView.layer.shadowRadius = 10 + self.contentViewControllerShadowView.layer.shadowOpacity = 0.3 + self.view.insertSubview(self.contentViewControllerShadowView, belowSubview: self.contentViewController.view) + + // Add scrollView to front so the scroll indicators are visible, but disable user interaction. + self.scrollView = UIScrollView(frame: CGRect(origin: .zero, size: self.view.bounds.size)) + self.scrollView.delegate = self + self.scrollView.isUserInteractionEnabled = false + self.scrollView.contentInsetAdjustmentBehavior = .never + self.view.addSubview(self.scrollView, pinningEdgesWith: .zero) + self.view.addGestureRecognizer(self.scrollView.panGestureRecognizer) + + self.contentViewController.scrollView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer) + self.contentViewController.scrollView.showsVerticalScrollIndicator = false + self.contentViewController.scrollView.contentInsetAdjustmentBehavior = .never + + + // Navigation Bar Title View + self.navigationBarNameLabel = UILabel(frame: .zero) + self.navigationBarNameLabel.font = UIFont.boldSystemFont(ofSize: 17) // We want semibold, which this (apparently) returns. + self.navigationBarNameLabel.text = self.title + self.navigationBarNameLabel.sizeToFit() + + self.navigationBarIconView = UIImageView(frame: .zero) + self.navigationBarIconView.clipsToBounds = true + + self.navigationBarTitleView = UIStackView(arrangedSubviews: [self.navigationBarIconView, self.navigationBarNameLabel]) + self.navigationBarTitleView.axis = .horizontal + self.navigationBarTitleView.spacing = 8 + + self.navigationBarButton = PillButton(type: .system) + self.navigationBarButton.setContentCompressionResistancePriority(UILayoutPriority(rawValue: 9000), for: .horizontal) // Prioritize over title length. + + // Embed navigationBarButton in container view with Auto Layout to ensure it can automatically update its size. + let buttonContainerView = UIView() + buttonContainerView.addSubview(self.navigationBarButton, pinningEdgesWith: .zero) + self.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: buttonContainerView) + + NSLayoutConstraint.activate([ + self.navigationBarIconView.widthAnchor.constraint(equalToConstant: 35), + self.navigationBarIconView.heightAnchor.constraint(equalTo: self.navigationBarIconView.widthAnchor) + ]) + + let size = self.navigationBarTitleView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + self.navigationBarTitleView.bounds.size = size + self.navigationItem.titleView = self.navigationBarTitleView + + self._backgroundBlurEffect = self.backgroundBlurView.effect as? UIBlurEffect + self._backgroundBlurTintColor = self.backgroundBlurView.contentView.backgroundColor + + self.contentSizeObservation = self.contentViewController.scrollView.observe(\.contentSize, options: [.new, .old]) { [weak self] (scrollView, change) in + guard let size = change.newValue, let previousSize = change.oldValue, size != previousSize else { return } + self?.view.setNeedsLayout() + self?.view.layoutIfNeeded() + } + + // Don't call update() before subclasses have finished viewDidLoad(). + // self.update() + + NotificationCenter.default.addObserver(self, selector: #selector(HeaderContentViewController.willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(HeaderContentViewController.didBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil) + + if #available(iOS 15, *) + { + // Fix navigation bar + tab bar appearance on iOS 15. + self.setContentScrollView(self.scrollView) + } + + // Start with navigation bar hidden. + self.hideNavigationBar() + } + + override func viewWillAppear(_ animated: Bool) + { + super.viewWillAppear(animated) + + self.prepareBlur() + + // Update blur immediately. + self.view.setNeedsLayout() + self.view.layoutIfNeeded() + + self.headerScrollView.flashScrollIndicators() + + self.update() + } + + override func viewDidAppear(_ animated: Bool) + { + super.viewDidAppear(animated) + + self._shouldResetLayout = true + self.view.setNeedsLayout() + self.view.layoutIfNeeded() + } + + override func viewDidLayoutSubviews() + { + super.viewDidLayoutSubviews() + + if self._shouldResetLayout + { + // Various events can cause UI to mess up, so reset affected components now. + + self.prepareBlur() + + // Reset navigation bar animation, and create a new one later in this method if necessary. + self.resetNavigationBarAnimation() + + self._shouldResetLayout = false + } + + //TODO: Dynamically calculate status bar height. + let statusBarHeight = 20.0 //self.view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0 + let cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius + + let inset = 15 as CGFloat + let padding = 20 as CGFloat + + let backButtonSize = self.backButton.sizeThatFits(CGSize(width: Double.infinity, height: .infinity)) + let largestBackButtonDimension = max(backButtonSize.width, backButtonSize.height) // Enforce 1:1 aspect ratio. + var backButtonFrame = CGRect(x: inset, y: statusBarHeight, width: largestBackButtonDimension, height: largestBackButtonDimension) + + var headerFrame = CGRect(x: inset, y: 0, width: self.view.bounds.width - inset * 2, height: self.headerView.bounds.height) + var contentFrame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: self.view.bounds.height) + var backgroundIconFrame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: self.view.bounds.width) + + let backButtonPadding = 8.0 + let minimumHeaderY = backButtonFrame.maxY + backButtonPadding + + let minimumContentHeight = minimumHeaderY + headerFrame.height + padding // Minimum height for header + back button + spacing. + let maximumContentY = max(self.view.bounds.width * 0.667, minimumContentHeight) // Initial Y-value of content view. + + contentFrame.origin.y = maximumContentY - self.scrollView.contentOffset.y + headerFrame.origin.y = contentFrame.origin.y - padding - headerFrame.height + + // Stretch the app icon image to fill additional vertical space if necessary. + let height = max(contentFrame.origin.y + cornerRadius * 2, backgroundIconFrame.height) + backgroundIconFrame.size.height = height + + // Update blur. + self.updateBlur() + + // Animate navigation bar. + let showNavigationBarThreshold = (maximumContentY - minimumContentHeight) + backButtonFrame.origin.y + if self.scrollView.contentOffset.y > showNavigationBarThreshold + { + if self.navigationBarAnimator == nil + { + self.prepareNavigationBarAnimation() + } + + let difference = self.scrollView.contentOffset.y - showNavigationBarThreshold + let range = maximumContentY - (maximumContentY - padding - headerFrame.height) - inset + + let fractionComplete = min(difference, range) / range + self.navigationBarAnimator?.fractionComplete = fractionComplete + } + else + { + self.navigationBarAnimator?.fractionComplete = 0.0 + self.resetNavigationBarAnimation() + } + + let beginMovingBackButtonThreshold = (maximumContentY - minimumContentHeight) + if self.scrollView.contentOffset.y > beginMovingBackButtonThreshold + { + let difference = self.scrollView.contentOffset.y - beginMovingBackButtonThreshold + backButtonFrame.origin.y -= difference + } + + let pinContentToTopThreshold = maximumContentY + if self.scrollView.contentOffset.y > pinContentToTopThreshold + { + contentFrame.origin.y = 0 + backgroundIconFrame.origin.y = 0 + + let difference = self.scrollView.contentOffset.y - pinContentToTopThreshold + self.contentViewController.scrollView.contentOffset.y = -self.contentViewController.scrollView.contentInset.top + difference + } + else + { + // Keep content table view's content offset at the top. + self.contentViewController.scrollView.contentOffset.y = -self.contentViewController.scrollView.contentInset.top + } + + // Keep background app icon centered in gap between top of content and top of screen. + backgroundIconFrame.origin.y = (contentFrame.origin.y / 2) - backgroundIconFrame.height / 2 + + // Set frames. + self.contentViewController.view.frame = contentFrame + self.contentViewControllerShadowView.frame = contentFrame + self.backgroundImageView.frame = backgroundIconFrame + + self.backButton.frame = backButtonFrame + self.backButton.layer.cornerRadius = backButtonFrame.height / 2 + + // Adjust header scroll view content size for paging + self.headerView.frame = CGRect(origin: .zero, size: headerFrame.size) + self.headerScrollView.frame = headerFrame + self.headerScrollView.contentSize = CGSize(width: headerFrame.width * 2, height: headerFrame.height) + + self.scrollView.verticalScrollIndicatorInsets.top = statusBarHeight + self.headerScrollView.horizontalScrollIndicatorInsets.bottom = -12 + + // Adjust content offset + size. + let contentOffset = self.scrollView.contentOffset + + var contentSize = self.contentViewController.scrollView.contentSize + contentSize.height += self.contentViewController.scrollView.contentInset.top + self.contentViewController.scrollView.contentInset.bottom + contentSize.height += maximumContentY + contentSize.height = max(contentSize.height, self.view.bounds.height + maximumContentY - (self.navigationController?.navigationBar.bounds.height ?? 0)) + self.scrollView.contentSize = contentSize + + self.scrollView.contentOffset = contentOffset + } + + func update() + { + // Overridden by subclasses. + } + + /// Cannot add @objc functions in extensions of generic types, so include them in main definition instead. + + //MARK: Notifications + + @objc private func willEnterForeground(_ notification: Notification) + { + guard let navigationController = self.navigationController, navigationController.topViewController == self else { return } + + self._shouldResetLayout = true + self.view.setNeedsLayout() + } + + @objc private func didBecomeActive(_ notification: Notification) + { + guard let navigationController = self.navigationController, navigationController.topViewController == self else { return } + + // Fixes incorrect blur after app becomes inactive -> active again. + self._shouldResetLayout = true + self.view.setNeedsLayout() + } + + //MARK: UIAdaptivePresentationControllerDelegate + + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool + { + return false + } + + //MARK: UIScrollViewDelegate + + func scrollViewDidScroll(_ scrollView: UIScrollView) + { + switch scrollView + { + case self.scrollView: self.view.setNeedsLayout() + case self.headerScrollView: + // Do NOT call setNeedsLayout(), or else it will mess with scrolling. + self.headerScrollView.showsHorizontalScrollIndicator = false + self.updateBlur() + + default: break + } + } +} + +private extension HeaderContentViewController +{ + func showNavigationBar() + { + self.navigationBarIconView.alpha = 1.0 + self.navigationBarNameLabel.alpha = 1.0 + self.navigationBarButton.alpha = 1.0 + + self.updateNavigationBarAppearance(isHidden: false) + + if self.traitCollection.userInterfaceStyle == .dark + { + self._preferredStatusBarStyle = .lightContent + } + else + { + self._preferredStatusBarStyle = .default + } + + self.navigationController?.setNeedsStatusBarAppearanceUpdate() + } + + func hideNavigationBar() + { + self.navigationBarIconView.alpha = 0.0 + self.navigationBarNameLabel.alpha = 0.0 + self.navigationBarButton.alpha = 0.0 + + self.updateNavigationBarAppearance(isHidden: true) + + self._preferredStatusBarStyle = .lightContent + + self.navigationController?.setNeedsStatusBarAppearanceUpdate() + } + + func updateNavigationBarAppearance(isHidden: Bool) + { + let barAppearance = self.navigationItem.standardAppearance ?? UINavigationBarAppearance() + + if isHidden + { + barAppearance.configureWithTransparentBackground() + } + else + { + barAppearance.configureWithDefaultBackground() + } + + barAppearance.titleTextAttributes = [.foregroundColor: UIColor.clear] + + let tintColor = isHidden ? UIColor.clear : self.tintColor ?? .altPrimary + + let buttonAppearance = UIBarButtonItemAppearance(style: .plain) + buttonAppearance.normal.titleTextAttributes = [.foregroundColor: tintColor] + barAppearance.buttonAppearance = buttonAppearance + + let backButtonImage = UIImage(systemName: "chevron.backward")?.withTintColor(tintColor, renderingMode: .alwaysOriginal) + barAppearance.setBackIndicatorImage(backButtonImage, transitionMaskImage: backButtonImage) + + self.navigationItem.standardAppearance = barAppearance + self.navigationItem.scrollEdgeAppearance = barAppearance + } + + func prepareBlur() + { + if let animator = self.blurAnimator + { + animator.stopAnimation(true) + } + + self.backgroundBlurView.effect = self._backgroundBlurEffect + self.backgroundBlurView.contentView.backgroundColor = self._backgroundBlurTintColor + + self.blurAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in + self?.backgroundBlurView.effect = nil + self?.backgroundBlurView.contentView.backgroundColor = .clear + } + + self.blurAnimator?.startAnimation() + self.blurAnimator?.pauseAnimation() + } + + func updateBlur() + { + // A full blur is too much for header, so we reduce the visible blur by 0.3, resulting in 70% blur. + let minimumBlurFraction = 0.3 as CGFloat + + if self.isViewingHeader + { + let maximumX = self.headerScrollView.bounds.width + let fraction = self.headerScrollView.contentOffset.x / maximumX + + let fractionComplete = (fraction * (1.0 - minimumBlurFraction)) + minimumBlurFraction + self.blurAnimator?.fractionComplete = fractionComplete + } + else if self.scrollView.contentOffset.y < 0 + { + // Determine how much to lessen blur by. + + let range = 75 as CGFloat + let difference = -self.scrollView.contentOffset.y + + let fraction = min(difference, range) / range + + let fractionComplete = (fraction * (1.0 - minimumBlurFraction)) + minimumBlurFraction + self.blurAnimator?.fractionComplete = fractionComplete + } + else + { + // Set blur to default. + self.blurAnimator?.fractionComplete = minimumBlurFraction + } + } + + func prepareNavigationBarAnimation() + { + self.resetNavigationBarAnimation() + + self.navigationBarAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in + self?.showNavigationBar() + + // Must call layoutIfNeeded() to animate appearance change. + self?.navigationController?.navigationBar.layoutIfNeeded() + + self?.contentViewController.view.layer.cornerRadius = 0 + } + self.navigationBarAnimator?.startAnimation() + self.navigationBarAnimator?.pauseAnimation() + + self.update() + } + + func resetNavigationBarAnimation() + { + guard self.navigationBarAnimator != nil else { return } + + self.navigationBarAnimator?.stopAnimation(true) + self.navigationBarAnimator = nil + + self.hideNavigationBar() + + self.contentViewController.view.layer.cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius + } +} diff --git a/AltStore/Components/PillButton.swift b/AltStore/Components/PillButton.swift index be801112..c94b5ca9 100644 --- a/AltStore/Components/PillButton.swift +++ b/AltStore/Components/PillButton.swift @@ -85,10 +85,27 @@ final class PillButton: UIButton self.displayLink.remove(from: .main, forMode: RunLoop.Mode.default) } + override init(frame: CGRect) + { + super.init(frame: frame) + + self.initialize() + } + + required init?(coder: NSCoder) + { + super.init(coder: coder) + } + override func awakeFromNib() { super.awakeFromNib() + self.initialize() + } + + private func initialize() + { self.layer.masksToBounds = true self.accessibilityTraits.formUnion([.updatesFrequently, .button]) @@ -153,6 +170,9 @@ private extension PillButton } self.progressView.progressTintColor = self.tintColor + + // Update font after init because the original titleLabel is replaced. + self.titleLabel?.font = UIFont.boldSystemFont(ofSize: 14) } @objc func updateCountdown() diff --git a/AltStore/Components/VibrantButton.swift b/AltStore/Components/VibrantButton.swift new file mode 100644 index 00000000..10761db6 --- /dev/null +++ b/AltStore/Components/VibrantButton.swift @@ -0,0 +1,150 @@ +// +// VibrantButton.swift +// AltStore +// +// Created by Riley Testut on 3/22/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit + +private let preferredFont = UIFont.boldSystemFont(ofSize: 14) + +class VibrantButton: UIButton +{ + var title: String? { + didSet { + if #available(iOS 15, *) + { + self.configuration?.title = self.title + } + else + { + self.setTitle(self.title, for: .normal) + } + } + } + + var image: UIImage? { + didSet { + if #available(iOS 15, *) + { + self.configuration?.image = self.image + } + else + { + self.setImage(self.image, for: .normal) + } + } + } + + var contentInsets: NSDirectionalEdgeInsets = .zero { + didSet { + if #available(iOS 15, *) + { + self.configuration?.contentInsets = self.contentInsets + } + else + { + self.contentEdgeInsets = UIEdgeInsets(top: self.contentInsets.top, left: self.contentInsets.leading, bottom: self.contentInsets.bottom, right: self.contentInsets.trailing) + } + } + } + + override var isIndicatingActivity: Bool { + didSet { + guard #available(iOS 15, *) else { return } + self.updateConfiguration() + } + } + + private let vibrancyView = UIVisualEffectView(effect: nil) + + override init(frame: CGRect) + { + super.init(frame: frame) + + self.initialize() + } + + required init?(coder: NSCoder) + { + super.init(coder: coder) + + self.initialize() + } + + private func initialize() + { + let blurEffect = UIBlurEffect(style: .systemThinMaterial) + let vibrancyEffect = UIVibrancyEffect(blurEffect: blurEffect, style: .fill) // .fill is more vibrant than .secondaryLabel + + if #available(iOS 15, *) + { + var backgroundConfig = UIBackgroundConfiguration.clear() + backgroundConfig.visualEffect = blurEffect + + var config = UIButton.Configuration.plain() + config.cornerStyle = .capsule + config.background = backgroundConfig + config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { [weak self] (attributes) in + var attributes = attributes + attributes.font = preferredFont + + if let self, self.isIndicatingActivity + { + // Hide title when indicating activity, but without changing intrinsicContentSize. + attributes.foregroundColor = UIColor.clear + } + + return attributes + } + + self.configuration = config + } + else + { + self.clipsToBounds = true + self.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8) // Add padding. + + let blurView = UIVisualEffectView(effect: blurEffect) + blurView.isUserInteractionEnabled = false + self.addSubview(blurView, pinningEdgesWith: .zero) + self.insertSubview(blurView, at: 0) + } + + self.vibrancyView.effect = vibrancyEffect + self.vibrancyView.isUserInteractionEnabled = false + self.addSubview(self.vibrancyView, pinningEdgesWith: .zero) + } + + override func layoutSubviews() + { + super.layoutSubviews() + + self.layer.cornerRadius = self.bounds.midY + + // Make sure content subviews are inside self.vibrancyView.contentView. + + if let titleLabel = self.titleLabel, titleLabel.superview != self.vibrancyView.contentView + { + self.vibrancyView.contentView.addSubview(titleLabel) + } + + if let imageView = self.imageView, imageView.superview != self.vibrancyView.contentView + { + self.vibrancyView.contentView.addSubview(imageView) + } + + if self.activityIndicatorView.superview != self.vibrancyView.contentView + { + self.vibrancyView.contentView.addSubview(self.activityIndicatorView) + } + + if #unavailable(iOS 15) + { + // Update font after init because the original titleLabel is replaced. + self.titleLabel?.font = preferredFont + } + } +} diff --git a/AltStore/Sources/SourceDetailContentViewController.swift b/AltStore/Sources/SourceDetailContentViewController.swift new file mode 100644 index 00000000..5630cac5 --- /dev/null +++ b/AltStore/Sources/SourceDetailContentViewController.swift @@ -0,0 +1,341 @@ +// +// SourcesDetailContentViewController.swift +// AltStore +// +// Created by Riley Testut on 3/8/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit + +import AltStoreCore +import Roxas + +import Nuke + +private let sectionInset = 20.0 + +extension SourceDetailContentViewController +{ + private enum Section: Int + { + case news + case featuredApps + case about + } + + private enum ElementKind: String + { + case title + case button + } +} + +class SourceDetailContentViewController: UICollectionViewController +{ + let source: Source + + private lazy var dataSource = self.makeDataSource() + private lazy var newsDataSource = self.makeNewsDataSource() + private lazy var appsDataSource = self.makeAppsDataSource() + private lazy var aboutDataSource = self.makeAboutDataSource() + + override var collectionViewLayout: UICollectionViewCompositionalLayout { + return self.collectionView.collectionViewLayout as! UICollectionViewCompositionalLayout + } + + init?(source: Source, coder: NSCoder) + { + self.source = source + + super.init(coder: coder) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() + { + super.viewDidLoad() + + self.view.tintColor = self.source.effectiveTintColor + + let collectionViewLayout = self.makeLayout(source: self.source) + self.collectionView.collectionViewLayout = collectionViewLayout + + self.collectionView.register(NewsCollectionViewCell.nib, forCellWithReuseIdentifier: "NewsCell") + self.collectionView.register(TitleCollectionReusableView.self, forSupplementaryViewOfKind: ElementKind.title.rawValue, withReuseIdentifier: ElementKind.title.rawValue) + self.collectionView.register(ButtonCollectionReusableView.self, forSupplementaryViewOfKind: ElementKind.button.rawValue, withReuseIdentifier: ElementKind.button.rawValue) + + self.dataSource.proxy = self + self.collectionView.dataSource = self.dataSource + self.collectionView.prefetchDataSource = self.dataSource + } + + override func viewSafeAreaInsetsDidChange() + { + super.viewSafeAreaInsetsDidChange() + + // Add sectionInset to safeAreaInsets.bottom. + self.collectionView.contentInset = UIEdgeInsets(top: sectionInset, left: 0, bottom: self.view.safeAreaInsets.bottom + sectionInset, right: 0) + } +} + +private extension SourceDetailContentViewController +{ + func makeLayout(source: Source) -> UICollectionViewCompositionalLayout + { + let layoutConfig = UICollectionViewCompositionalLayoutConfiguration() + layoutConfig.interSectionSpacing = 10 + + let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in + guard let section = Section(rawValue: sectionIndex) else { return nil } + + switch section + { + case .news: + guard !source.newsItems.isEmpty else { return nil } + + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(50)) // Underestimate height to prevent jumping size abruptly. + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupWidth = layoutEnvironment.container.contentSize.width - sectionInset * 2 + let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(groupWidth), heightDimension: .estimated(50)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + let buttonSize = NSCollectionLayoutSize(widthDimension: .estimated(60), heightDimension: .estimated(20)) + let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: buttonSize, elementKind: ElementKind.button.rawValue, alignment: .bottomTrailing) + + let layoutSection = NSCollectionLayoutSection(group: group) + layoutSection.interGroupSpacing = 10 + layoutSection.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: sectionInset, bottom: 4, trailing: sectionInset) + layoutSection.orthogonalScrollingBehavior = .groupPagingCentered + layoutSection.boundarySupplementaryItems = [sectionFooter] + return layoutSection + + case .featuredApps: + // Always show Featured Apps section, even if there are no apps. + // guard !source.effectiveFeaturedApps.isEmpty else { return nil } + + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(88)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item]) + + let titleSize = NSCollectionLayoutSize(widthDimension: .estimated(75), heightDimension: .estimated(40)) + let titleHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.title.rawValue, alignment: .topLeading) + + let buttonSize = NSCollectionLayoutSize(widthDimension: .estimated(60), heightDimension: .estimated(20)) + let buttonHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: buttonSize, elementKind: ElementKind.button.rawValue, alignment: .bottomTrailing) + + let layoutSection = NSCollectionLayoutSection(group: group) + layoutSection.interGroupSpacing = 15 + layoutSection.contentInsets = NSDirectionalEdgeInsets(top: 15 /* independent of sectionInset */, leading: sectionInset, bottom: 4, trailing: sectionInset) + layoutSection.orthogonalScrollingBehavior = .none + layoutSection.boundarySupplementaryItems = [titleHeader, buttonHeader] + return layoutSection + + case .about: + guard source.localizedDescription != nil else { return nil } + + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(200)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item]) + + let titleSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(40)) + let titleHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.title.rawValue, alignment: .topLeading) + + let layoutSection = NSCollectionLayoutSection(group: group) + layoutSection.contentInsets = NSDirectionalEdgeInsets(top: 15 /* independent of sectionInset */, leading: sectionInset, bottom: 0, trailing: sectionInset) + layoutSection.orthogonalScrollingBehavior = .none + layoutSection.boundarySupplementaryItems = [titleHeader] + return layoutSection + } + }, configuration: layoutConfig) + + return layout + } + + func makeDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource + { + let newsDataSource = self.newsDataSource as! RSTFetchedResultsCollectionViewDataSource + let appsDataSource = self.appsDataSource as! RSTArrayCollectionViewPrefetchingDataSource + + let dataSource = RSTCompositeCollectionViewPrefetchingDataSource(dataSources: [newsDataSource, appsDataSource, self.aboutDataSource]) + return dataSource + } + + func makeNewsDataSource() -> RSTFetchedResultsCollectionViewDataSource + { + let fetchRequest = NewsItem.sortedFetchRequest(for: self.source) + + let context = self.source.managedObjectContext ?? DatabaseManager.shared.viewContext + let dataSource = RSTFetchedResultsCollectionViewDataSource(fetchRequest: fetchRequest, managedObjectContext: context) + dataSource.liveFetchLimit = 5 + dataSource.cellIdentifierHandler = { _ in "NewsCell" } + dataSource.cellConfigurationHandler = { (cell, newsItem, indexPath) in + let cell = cell as! NewsCollectionViewCell + + // For some reason, setting cell.layoutMargins = .zero does not update cell.contentView.layoutMargins. + cell.layoutMargins = .zero + cell.contentView.layoutMargins = .zero + + cell.titleLabel.text = newsItem.title + cell.captionLabel.text = newsItem.caption + cell.contentBackgroundView.backgroundColor = newsItem.tintColor + + cell.imageView.image = nil + cell.imageView.isHidden = true + + cell.isAccessibilityElement = true + cell.accessibilityLabel = (cell.titleLabel.text ?? "") + ". " + (cell.captionLabel.text ?? "") + + if newsItem.storeApp != nil || newsItem.externalURL != nil + { + cell.accessibilityTraits.insert(.button) + } + else + { + cell.accessibilityTraits.remove(.button) + } + } + + return dataSource + } + + func makeAppsDataSource() -> RSTArrayCollectionViewPrefetchingDataSource + { + let featuredApps = self.source.effectiveFeaturedApps + let limitedFeaturedApps = Array(featuredApps.prefix(5)) + + let dataSource = RSTArrayCollectionViewPrefetchingDataSource(items: limitedFeaturedApps) + dataSource.cellIdentifierHandler = { _ in "AppCell" } + dataSource.predicate = NSPredicate(format: "%K == NO", #keyPath(StoreApp.isBeta)) // Never show beta apps (at least until we support betas for other sources). + dataSource.cellConfigurationHandler = { [weak self] (cell, storeApp, indexPath) in + let cell = cell as! AppBannerCollectionViewCell + cell.tintColor = storeApp.tintColor + + // For some reason, setting cell.layoutMargins = .zero does not update cell.contentView.layoutMargins. + cell.layoutMargins = .zero + cell.contentView.layoutMargins = .zero + + cell.bannerView.configure(for: storeApp) + + cell.bannerView.iconImageView.isIndicatingActivity = true + cell.bannerView.buttonLabel.isHidden = true + + cell.bannerView.button.isIndicatingActivity = false + cell.bannerView.button.tintColor = storeApp.tintColor + + let buttonTitle = NSLocalizedString("Free", comment: "") + cell.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal) + cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), storeApp.name) + cell.bannerView.button.accessibilityValue = buttonTitle + + let progress = AppManager.shared.installationProgress(for: storeApp) + cell.bannerView.button.progress = progress + + if let versionDate = storeApp.latestSupportedVersion?.date, versionDate > Date() + { + cell.bannerView.button.countdownDate = versionDate + } + else + { + cell.bannerView.button.countdownDate = nil + } + + // Make sure refresh button is correct size. + cell.layoutIfNeeded() + + if let progress = AppManager.shared.installationProgress(for: storeApp), progress.fractionCompleted < 1.0 + { + cell.bannerView.button.progress = progress + } + else + { + cell.bannerView.button.progress = nil + } + } + dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in + return RSTAsyncBlockOperation { (operation) in + storeApp.managedObjectContext?.perform { + ImagePipeline.shared.loadImage(with: storeApp.iconURL, progress: nil) { result in + guard !operation.isCancelled else { return operation.finish() } + + switch result + { + case .success(let response): completion(response.image, nil) + case .failure(let error): completion(nil, error) + } + } + } + } + } + dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in + let cell = cell as! AppBannerCollectionViewCell + cell.bannerView.iconImageView.image = image + cell.bannerView.iconImageView.isIndicatingActivity = false + + if let error + { + print("[ALTLog] Error loading source icon:", error) + } + } + + return dataSource + } + + func makeAboutDataSource() -> RSTDynamicCollectionViewDataSource + { + let dataSource = RSTDynamicCollectionViewDataSource() + dataSource.numberOfSectionsHandler = { 1 } + dataSource.numberOfItemsHandler = { _ in self.source.localizedDescription == nil ? 0 : 1 } + dataSource.cellIdentifierHandler = { _ in "AboutCell" } + dataSource.cellConfigurationHandler = { [weak self] (cell, _, indexPath) in + let cell = cell as! TextViewCollectionViewCell + cell.contentView.layoutMargins = .zero // Fixes incorrect margins if not initially on screen. + cell.textView.text = self?.source.localizedDescription + cell.textView.isCollapsed = false + } + + return dataSource + } +} + +extension SourceDetailContentViewController +{ + override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView + { + let supplementaryView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) + + let section = Section(rawValue: indexPath.section)! + let kind = ElementKind(rawValue: kind)! + switch (section, kind) + { + case (.news, _): + let buttonView = supplementaryView as! ButtonCollectionReusableView + buttonView.button.setTitle(NSLocalizedString("View All", comment: ""), for: .normal) + + case (.featuredApps, .title): + let titleView = supplementaryView as! TitleCollectionReusableView + titleView.label.text = NSLocalizedString("Featured Apps", comment: "") + + case (.featuredApps, .button): + let buttonView = supplementaryView as! ButtonCollectionReusableView + buttonView.button.setTitle(NSLocalizedString("View All Apps", comment: ""), for: .normal) + + case (.about, _): + let titleView = supplementaryView as! TitleCollectionReusableView + titleView.label.text = NSLocalizedString("About", comment: "") + } + + return supplementaryView + } +} + +extension SourceDetailContentViewController: ScrollableContentViewController +{ + var scrollView: UIScrollView { self.collectionView } +} diff --git a/AltStore/Sources/SourceDetailViewController.swift b/AltStore/Sources/SourceDetailViewController.swift new file mode 100644 index 00000000..1b58d2b7 --- /dev/null +++ b/AltStore/Sources/SourceDetailViewController.swift @@ -0,0 +1,108 @@ +// +// SourceDetailViewController.swift +// AltStore +// +// Created by Riley Testut on 3/15/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit + +import AltStoreCore +import Roxas + +import Nuke + +class SourceDetailViewController: HeaderContentViewController +{ + @Managed private(set) var source: Source + + private var addButton: VibrantButton! + + private var previousBounds: CGRect? + + init?(source: Source, coder: NSCoder) + { + self.source = source + super.init(coder: coder) + + self.title = source.name + self.tintColor = source.effectiveTintColor + } + + required init?(coder: NSCoder) + { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() + { + super.viewDidLoad() + + self.addButton = VibrantButton(type: .system) + self.addButton.title = NSLocalizedString("ADD", comment: "") + self.addButton.contentInsets = PillButton.contentInsets + self.addButton.sizeToFit() + self.view.addSubview(self.addButton) + + Nuke.loadImage(with: self.source.effectiveIconURL, into: self.navigationBarIconView) + Nuke.loadImage(with: self.source.effectiveHeaderImageURL, into: self.backgroundImageView) + + self.update() + } + + override func viewDidLayoutSubviews() + { + super.viewDidLayoutSubviews() + + self.addButton.layer.cornerRadius = self.addButton.bounds.midY + self.navigationBarIconView.layer.cornerRadius = self.navigationBarIconView.bounds.midY + + var addButtonSize = self.addButton.sizeThatFits(CGSize(width: Double.infinity, height: .infinity)) + addButtonSize.width = max(addButtonSize.width, PillButton.minimumSize.width) + addButtonSize.height = max(addButtonSize.height, PillButton.minimumSize.height) + self.addButton.frame.size = addButtonSize + + // Place in top-right corner. + let inset = 15.0 + self.addButton.center.y = self.backButton.center.y + self.addButton.frame.origin.x = self.view.bounds.width - inset - self.addButton.bounds.width + + guard self.view.bounds != self.previousBounds else { return } + self.previousBounds = self.view.bounds + + let headerSize = self.headerView.systemLayoutSizeFitting(CGSize(width: self.view.bounds.width - inset * 2, height: UIView.layoutFittingCompressedSize.height)) + self.headerView.frame.size.height = headerSize.height + } + + //MARK: Override + + override func makeContentViewController() -> SourceDetailContentViewController + { + guard let storyboard = self.storyboard else { fatalError("SourceDetailViewController must be initialized via UIStoryboard.") } + + let contentViewController = storyboard.instantiateViewController(identifier: "sourceDetailContentViewController") { coder in + SourceDetailContentViewController(source: self.source, coder: coder) + } + return contentViewController + } + + override func makeHeaderView() -> SourceHeaderView + { + let sourceAboutView = SourceHeaderView(frame: CGRect(x: 0, y: 0, width: 375, height: 200)) + sourceAboutView.configure(for: self.source) + return sourceAboutView + } + + override func update() + { + super.update() + + if self.source.identifier == Source.altStoreIdentifier + { + // Users can't remove default AltStore source, so hide buttons. + self.addButton.isHidden = true + self.navigationBarButton.isHidden = true + } + } +} diff --git a/AltStore/Sources/SourceDetailsComponents.swift b/AltStore/Sources/SourceDetailsComponents.swift new file mode 100644 index 00000000..570b3556 --- /dev/null +++ b/AltStore/Sources/SourceDetailsComponents.swift @@ -0,0 +1,89 @@ +// +// SourceDetailsComponents.swift +// AltStore +// +// Created by Riley Testut on 3/16/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit + +import Roxas + +class TitleCollectionReusableView: UICollectionReusableView +{ + let label: UILabel + + override init(frame: CGRect) + { + let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .largeTitle).withSymbolicTraits(.traitBold)! + let font = UIFont(descriptor: fontDescriptor, size: 0.0) + + self.label = UILabel(frame: .zero) + self.label.font = font + + super.init(frame: frame) + + self.addSubview(self.label, pinningEdgesWith: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class ButtonCollectionReusableView: UICollectionReusableView +{ + let button: UIButton + + override init(frame: CGRect) + { + self.button = UIButton(type: .system) + self.button.translatesAutoresizingMaskIntoConstraints = false + + super.init(frame: frame) + + self.addSubview(self.button, pinningEdgesWith: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class TextViewCollectionViewCell: UICollectionViewCell +{ + let textView = CollapsingTextView(frame: .zero) + + override init(frame: CGRect) + { + super.init(frame: frame) + + self.initialize() + } + + required init?(coder: NSCoder) + { + super.init(coder: coder) + + self.initialize() + } + + private func initialize() + { + self.textView.font = UIFont.preferredFont(forTextStyle: .body) + self.textView.isScrollEnabled = false + self.textView.isEditable = false + self.textView.isSelectable = true + self.textView.dataDetectorTypes = [.link] + self.contentView.addSubview(self.textView, pinningEdgesWith: .zero) + } + + override func layoutMarginsDidChange() + { + super.layoutMarginsDidChange() + + self.textView.textContainerInset.left = self.contentView.layoutMargins.left + self.textView.textContainerInset.right = self.contentView.layoutMargins.right + } +} diff --git a/AltStore/Sources/SourceHeaderView.swift b/AltStore/Sources/SourceHeaderView.swift new file mode 100644 index 00000000..a81e65d9 --- /dev/null +++ b/AltStore/Sources/SourceHeaderView.swift @@ -0,0 +1,106 @@ +// +// SourceHeaderView.swift +// AltStore +// +// Created by Riley Testut on 3/9/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit + +import AltStoreCore +import Roxas + +import Nuke + +class SourceHeaderView: RSTNibView +{ + @IBOutlet private(set) var titleLabel: UILabel! + @IBOutlet private(set) var subtitleLabel: UILabel! + @IBOutlet private(set) var iconImageView: UIImageView! + @IBOutlet private(set) var websiteButton: UIButton! + + @IBOutlet private var websiteContentView: UIView! + @IBOutlet private var websiteButtonContainerView: UIView! + @IBOutlet private var websiteImageView: UIImageView! + + @IBOutlet private var widthConstraint: NSLayoutConstraint! + + override init(frame: CGRect) + { + super.init(frame: frame) + + self.initialize() + } + + required init?(coder: NSCoder) + { + super.init(coder: coder) + + self.initialize() + } + + private func initialize() + { + self.clipsToBounds = true + self.layer.cornerRadius = 22 + + self.iconImageView.clipsToBounds = true + + let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .title3).withSymbolicTraits(.traitBold)! + let titleFont = UIFont(descriptor: fontDescriptor, size: 0.0) + self.titleLabel.font = titleFont + + self.websiteButton.setTitle(nil, for: .normal) + self.websiteButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .subheadline) + + let imageConfiguration = UIImage.SymbolConfiguration(scale: .medium) + let websiteImage = UIImage(systemName: "link", withConfiguration: imageConfiguration) + self.websiteImageView.image = websiteImage + + self.websiteButtonContainerView.clipsToBounds = true + self.websiteButtonContainerView.layer.cornerRadius = 14 // 22 - inset (8) + } + + override func layoutSubviews() + { + super.layoutSubviews() + + self.iconImageView.layer.cornerRadius = self.iconImageView.bounds.midY + + if let titleLabel = self.websiteButton.titleLabel, self.widthConstraint.constant == 0 + { + // Left-align website button text with subtitle by increasing width by label inset. + let frame = self.websiteButton.convert(titleLabel.frame, from: titleLabel.superview) + self.widthConstraint.constant = frame.minX + } + } +} + +extension SourceHeaderView +{ + func configure(for source: Source) + { + self.titleLabel.text = source.name + self.subtitleLabel.text = source.subtitle + + self.websiteImageView.tintColor = source.effectiveTintColor + + if let websiteURL = source.websiteURL + { + self.websiteButton.setTitle(websiteURL.absoluteString, for: .normal) + + self.websiteContentView.isHidden = false + self.websiteImageView.isHidden = false + } + else + { + self.websiteButton.setTitle(nil, for: .normal) + + self.websiteContentView.isHidden = true + self.websiteImageView.isHidden = true + } + + Nuke.loadImage(with: source.effectiveIconURL, into: self.iconImageView) + } +} diff --git a/AltStore/Sources/SourceHeaderView.xib b/AltStore/Sources/SourceHeaderView.xib new file mode 100644 index 00000000..b926ecec --- /dev/null +++ b/AltStore/Sources/SourceHeaderView.xib @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AltStore/Sources/Sources.storyboard b/AltStore/Sources/Sources.storyboard index 87238e59..d6e973c4 100644 --- a/AltStore/Sources/Sources.storyboard +++ b/AltStore/Sources/Sources.storyboard @@ -4,7 +4,9 @@ + + @@ -127,12 +129,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AltStore/Sources/SourcesViewController.swift b/AltStore/Sources/SourcesViewController.swift index 2dd28018..202b191c 100644 --- a/AltStore/Sources/SourcesViewController.swift +++ b/AltStore/Sources/SourcesViewController.swift @@ -199,6 +199,15 @@ private extension SourcesViewController let dataSource = RSTArrayCollectionViewDataSource(items: []) return dataSource } + + @IBSegueAction + func makeSourceDetailViewController(_ coder: NSCoder, sender: Any?) -> UIViewController? + { + guard let source = sender as? Source else { return nil } + + let sourceDetailViewController = SourceDetailViewController(source: source, coder: coder) + return sourceDetailViewController + } } private extension SourcesViewController @@ -229,7 +238,7 @@ private extension SourcesViewController self.present(alertController, animated: true, completion: nil) } - func addSource(url: URL, isTrusted: Bool = false, completionHandler: ((Result) -> Void)? = nil) + func addSource(url: URL, completionHandler: ((Result) -> Void)? = nil) { guard self.view.window != nil else { return } @@ -262,6 +271,7 @@ private extension SourcesViewController } } + //TODO: Remove this now that trusted sources aren't necessary. var dependencies: [Foundation.Operation] = [] if let fetchTrustedSourcesOperation = self.fetchTrustedSourcesOperation { @@ -273,34 +283,13 @@ private extension SourcesViewController AppManager.shared.fetchSource(sourceURL: url, dependencies: dependencies) { (result) in do { - let source = try result.get() - let sourceName = source.name - let managedObjectContext = source.managedObjectContext - - // Hide warning when adding a featured trusted source. - let message = isTrusted ? nil : NSLocalizedString("Make sure to only add sources that you trust.", comment: "") + @Managed var source = try result.get() DispatchQueue.main.async { - let alertController = UIAlertController(title: String(format: NSLocalizedString("Would you like to add the source “%@”?", comment: ""), sourceName), - message: message, preferredStyle: .alert) - alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { _ in - finish(.failure(OperationError.cancelled)) - }) - alertController.addAction(UIAlertAction(title: NSLocalizedString("Add Source", comment: ""), style: UIAlertAction.ok.style) { _ in - managedObjectContext?.perform { - do - { - try managedObjectContext?.save() - finish(.success(())) - } - catch - { - finish(.failure(error)) - } - } - }) - self.present(alertController, animated: true, completion: nil) + self.showSourceDetails(for: source) } + + finish(.success(())) } catch { @@ -416,7 +405,7 @@ private extension SourcesViewController sender.progress = completedProgress let source = self.dataSource.item(at: indexPath) - self.addSource(url: source.sourceURL, isTrusted: true) { _ in + self.addSource(url: source.sourceURL) { _ in //FIXME: Handle cell reuse. sender.progress = nil } @@ -451,6 +440,11 @@ private extension SourcesViewController self.present(alertController, animated: true, completion: nil) } + + func showSourceDetails(for source: Source) + { + self.performSegue(withIdentifier: "showSourceDetails", sender: source) + } } extension SourcesViewController @@ -460,9 +454,7 @@ extension SourcesViewController self.collectionView.deselectItem(at: indexPath, animated: true) let source = self.dataSource.item(at: indexPath) - guard let error = source.error else { return } - - self.present(error) + self.showSourceDetails(for: source) } } @@ -613,7 +605,7 @@ extension SourcesViewController } let addAction = UIAction(title: String(format: NSLocalizedString("Add “%@”", comment: ""), source.name), image: UIImage(systemName: "plus")) { (action) in - self.addSource(url: source.sourceURL, isTrusted: true) + self.addSource(url: source.sourceURL) } var actions: [UIAction] = [] diff --git a/AltStoreCore/Model/NewsItem.swift b/AltStoreCore/Model/NewsItem.swift index 22e86eb8..f3c98377 100644 --- a/AltStoreCore/Model/NewsItem.swift +++ b/AltStoreCore/Model/NewsItem.swift @@ -88,4 +88,20 @@ public extension NewsItem { return NSFetchRequest(entityName: "NewsItem") } + + class func sortedFetchRequest(for source: Source?) -> NSFetchRequest + { + let fetchRequest = NewsItem.fetchRequest() as NSFetchRequest + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \NewsItem.date, ascending: false), + NSSortDescriptor(keyPath: \NewsItem.sortIndex, ascending: true), + NSSortDescriptor(keyPath: \NewsItem.sourceIdentifier, ascending: true)] + + if let source + { + fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(NewsItem.source), source) + } + + return fetchRequest + } }