Compare commits

..

6 Commits

8 changed files with 563 additions and 99 deletions

View File

@ -141,6 +141,9 @@
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */; };
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */; };
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */; };
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9026C80DC600FC57FB /* ToastView.swift */; };
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */; };
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */; };
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18523C1253A000D0238 /* AssetPreviewViewController.swift */; };
D64BC18823C1640A000D0238 /* PinStatusActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18723C1640A000D0238 /* PinStatusActivity.swift */; };
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18923C16487000D0238 /* UnpinStatusActivity.swift */; };
@ -544,6 +547,9 @@
D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageShrinkAnimationController.swift; sourceTree = "<group>"; };
D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageInteractionController.swift; sourceTree = "<group>"; };
D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewViewController.swift; sourceTree = "<group>"; };
D64AAE9026C80DC600FC57FB /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = "<group>"; };
D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastableViewController.swift; sourceTree = "<group>"; };
D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastConfiguration.swift; sourceTree = "<group>"; };
D64BC18523C1253A000D0238 /* AssetPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPreviewViewController.swift; sourceTree = "<group>"; };
D64BC18723C1640A000D0238 /* PinStatusActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinStatusActivity.swift; sourceTree = "<group>"; };
D64BC18923C16487000D0238 /* UnpinStatusActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnpinStatusActivity.swift; sourceTree = "<group>"; };
@ -1062,27 +1068,27 @@
D641C780213DD7C4004B4513 /* Screens */ = {
isa = PBXGroup;
children = (
D6C693FA2162FE5D007D6A6D /* Utilities */,
D6F2E960249E772F005846BB /* Crash Reporter */,
D641C782213DD7F0004B4513 /* Main */,
D641C783213DD7FE004B4513 /* Onboarding */,
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */,
D641C781213DD7DD004B4513 /* Timeline */,
D641C784213DD819004B4513 /* Profile */,
D641C785213DD83B004B4513 /* Conversation */,
D641C786213DD852004B4513 /* Notifications */,
D641C787213DD862004B4513 /* Compose */,
D6A3BC822321F69400FD64D5 /* Account List */,
D6B053A023BD2BED00A066FA /* Asset Picker */,
0411610522B457290030A9B7 /* Attachment Gallery */,
D627944823A6AD5100D38C68 /* Bookmarks */,
D641C787213DD862004B4513 /* Compose */,
D641C785213DD83B004B4513 /* Conversation */,
D6F2E960249E772F005846BB /* Crash Reporter */,
D627FF77217E94F200CC0648 /* Drafts */,
D627943C23A5635D00D38C68 /* Explore */,
D6BC9DD8232D8BCA002CA326 /* Search */,
D627944B23A9A02400D38C68 /* Lists */,
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */,
D641C788213DD86D004B4513 /* Large Image */,
0411610522B457290030A9B7 /* Attachment Gallery */,
D6A3BC822321F69400FD64D5 /* Account List */,
D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */,
D627944823A6AD5100D38C68 /* Bookmarks */,
D627944B23A9A02400D38C68 /* Lists */,
D641C782213DD7F0004B4513 /* Main */,
D641C786213DD852004B4513 /* Notifications */,
D641C783213DD7FE004B4513 /* Onboarding */,
D641C789213DD87E004B4513 /* Preferences */,
D641C784213DD819004B4513 /* Profile */,
D6BC9DD8232D8BCA002CA326 /* Search */,
D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */,
D641C781213DD7DD004B4513 /* Timeline */,
D6C693FA2162FE5D007D6A6D /* Utilities */,
);
path = Screens;
sourceTree = "<group>";
@ -1263,6 +1269,16 @@
path = Transitions;
sourceTree = "<group>";
};
D64AAE8F26C80DB600FC57FB /* Toast */ = {
isa = PBXGroup;
children = (
D64AAE9026C80DC600FC57FB /* ToastView.swift */,
D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */,
D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */,
);
path = Toast;
sourceTree = "<group>";
};
D65A37F221472F300087646E /* Frameworks */ = {
isa = PBXGroup;
children = (
@ -1454,35 +1470,36 @@
D6BED1722126661300F02DA0 /* Views */ = {
isa = PBXGroup;
children = (
D620483323D3801D008A63EF /* LinkTextView.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */,
D620483723D38190008A63EF /* StatusContentTextView.swift */,
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */,
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */,
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */,
04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
D620483323D3801D008A63EF /* LinkTextView.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */,
D620483723D38190008A63EF /* StatusContentTextView.swift */,
04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */,
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */,
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */,
D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */,
D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */,
D6A3BC872321F78000FD64D5 /* Account Cell */,
D67C57A721E2649B00C3118B /* Account Detail */,
D626494023C122C800612E6E /* Asset Picker */,
D61959D0241E842400A37B8E /* Draft Cell */,
D641C78A213DD926004B4513 /* Status */,
D6C7D27B22B6EBE200071952 /* Attachments */,
D623A53B2635F4E20095BD04 /* Poll */,
D641C78B213DD92F004B4513 /* Profile Header */,
D641C78C213DD937004B4513 /* Notifications */,
D6A3BC872321F78000FD64D5 /* Account Cell */,
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */,
D61959D0241E842400A37B8E /* Draft Cell */,
D611C2CC232DC5FC00C86A49 /* Hashtag Cell */,
D61AC1DA232EA43100C54D2D /* Instance Cell */,
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */,
D641C78C213DD937004B4513 /* Notifications */,
D623A53B2635F4E20095BD04 /* Poll */,
D641C78B213DD92F004B4513 /* Profile Header */,
D641C78A213DD926004B4513 /* Status */,
D64AAE8F26C80DB600FC57FB /* Toast */,
D6420AEB26BED17500ED8175 /* Timeline Description Cell */,
);
path = Views;
@ -1491,24 +1508,24 @@
D6C693FA2162FE5D007D6A6D /* Utilities */ = {
isa = PBXGroup;
children = (
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */,
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
D6E0DC8D216EDF1E00369478 /* Previewing.swift */,
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */,
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */,
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */,
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */,
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */,
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */,
D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */,
D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */,
D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */,
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */,
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */,
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */,
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */,
D6E0DC8D216EDF1E00369478 /* Previewing.swift */,
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */,
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */,
D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */,
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */,
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
);
path = Utilities;
sourceTree = "<group>";
@ -1555,36 +1572,36 @@
isa = PBXGroup;
children = (
D6E4885C24A2890C0011C13E /* Tusker.entitlements */,
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */,
D6D4DDDB212518A200E1C4BB /* Info.plist */,
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */,
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */,
D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */,
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */,
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */,
D6E57FA525C26FAB00341037 /* Localizable.stringsdict */,
D67B506B250B28FF00FAECFB /* Vendor */,
D6F1F84E2193B9BE00F5FE67 /* Caching */,
D6757A7A2157E00100721E32 /* XCallbackURL */,
D62D241E217AA46B005076CC /* Shortcuts */,
D663626021360A9600C9CBA2 /* Preferences */,
D6AEBB3F2321640F00E5038B /* Activities */,
D667E5F62135C2ED0057A976 /* Extensions */,
D61959D2241E846D00A37B8E /* Models */,
D6370B9924421FE00092A7FF /* CoreData */,
D6F953F121251A2F00CF0F2B /* Controllers */,
D641C780213DD7C4004B4513 /* Screens */,
D6BED1722126661300F02DA0 /* Views */,
D6D4DDD6212518A200E1C4BB /* Assets.xcassets */,
D6AEBB3F2321640F00E5038B /* Activities */,
D6F1F84E2193B9BE00F5FE67 /* Caching */,
D6F953F121251A2F00CF0F2B /* Controllers */,
D6370B9924421FE00092A7FF /* CoreData */,
D667E5F62135C2ED0057A976 /* Extensions */,
D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */,
D6D4DDDB212518A200E1C4BB /* Info.plist */,
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */,
D6E57FA525C26FAB00341037 /* Localizable.stringsdict */,
D61959D2241E846D00A37B8E /* Models */,
D663626021360A9600C9CBA2 /* Preferences */,
D641C780213DD7C4004B4513 /* Screens */,
D62D241E217AA46B005076CC /* Shortcuts */,
D67B506B250B28FF00FAECFB /* Vendor */,
D6BED1722126661300F02DA0 /* Views */,
D6757A7A2157E00100721E32 /* XCallbackURL */,
);
path = Tusker;
sourceTree = "<group>";
@ -2067,6 +2084,7 @@
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */,
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */,
@ -2148,6 +2166,7 @@
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */,
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */,
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */,
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */,
D6093FB025BE0B01004811E6 /* TrendingHashtagTableViewCell.swift in Sources */,
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */,
D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */,
@ -2162,6 +2181,7 @@
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */,
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */,
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */,
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,

View File

@ -89,3 +89,11 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
root.performSearch(query: query)
}
}
extension AccountSwitchingContainerViewController: BackgroundableViewController {
func sceneDidEnterBackground() {
if let backgroundable = root as? BackgroundableViewController {
backgroundable.sceneDidEnterBackground()
}
}
}

View File

@ -29,6 +29,8 @@ class InstanceSelectorTableViewController: UITableViewController {
var urlHandler: AnyCancellable?
var currentQuery: String?
private var activityIndicator: UIActivityIndicatorView!
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if UIDevice.current.userInterfaceIdiom == .phone {
return .portrait
@ -56,9 +58,9 @@ class InstanceSelectorTableViewController: UITableViewController {
navigationItem.scrollEdgeAppearance = appearance
tableView.register(UINib(nibName: "InstanceTableViewCell", bundle: .main), forCellReuseIdentifier: instanceCell)
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
createActivityIndicatorHeader()
dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
switch item {
@ -78,12 +80,19 @@ class InstanceSelectorTableViewController: UITableViewController {
searchController.obscuresBackgroundDuringPresentation = false
searchController.searchBar.searchTextField.autocapitalizationType = .none
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
definesPresentationContext = true
urlHandler = urlCheckerSubject
.debounce(for: .seconds(1), scheduler: RunLoop.main)
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.sink(receiveValue: updateSpecificInstance)
.map { [weak self] (s) -> String in
if !s.isEmpty {
self?.activityIndicator.startAnimating()
}
return s
}
.debounce(for: .seconds(1), scheduler: RunLoop.main)
.sink { [weak self] in self?.updateSpecificInstance(domain: $0) }
loadRecommendedInstances()
}
@ -117,6 +126,8 @@ class InstanceSelectorTableViewController: UITableViewController {
}
private func updateSpecificInstance(domain: String) {
activityIndicator.startAnimating()
let components = parseURLComponents(input: domain)
let url = components.url!
@ -138,7 +149,13 @@ class InstanceSelectorTableViewController: UITableViewController {
snapshot.appendItems([.selected(url, instance)], toSection: .selected)
DispatchQueue.main.async {
self.dataSource.apply(snapshot)
self.dataSource.apply(snapshot) {
self.activityIndicator.stopAnimating()
}
}
} else {
DispatchQueue.main.async {
self.activityIndicator.stopAnimating()
}
}
}
@ -158,6 +175,28 @@ class InstanceSelectorTableViewController: UITableViewController {
}
}
private func createActivityIndicatorHeader() {
let header = UITableViewHeaderFooterView()
header.translatesAutoresizingMaskIntoConstraints = false
header.contentView.backgroundColor = .secondarySystemBackground
activityIndicator = UIActivityIndicatorView(style: .large)
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
header.contentView.addSubview(activityIndicator)
NSLayoutConstraint.activate([
activityIndicator.centerXAnchor.constraint(equalTo: header.contentView.centerXAnchor),
activityIndicator.topAnchor.constraint(equalTo: header.contentView.topAnchor, constant: 4),
activityIndicator.bottomAnchor.constraint(equalTo: header.contentView.bottomAnchor, constant: -4),
])
let fittingSize = CGSize(width: tableView.bounds.width - (tableView.safeAreaInsets.left + tableView.safeAreaInsets.right), height: 0)
let size = header.systemLayoutSizeFitting(fittingSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
header.frame = CGRect(origin: .zero, size: size)
tableView.tableHeaderView = header
}
private func showRecommendationsError(_ error: Client.Error) {
let footer = UITableViewHeaderFooterView()
footer.translatesAutoresizingMaskIntoConstraints = false

View File

@ -70,8 +70,8 @@ class ProfileViewController: UIPageViewController {
let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning))
composeButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: [
UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
self.composeDirectMentioning()
UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] (_) in
self?.composeDirectMentioning()
})
])
composeButton.isEnabled = mastodonController.loggedIn

View File

@ -59,6 +59,7 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
super.viewDidDisappear(animated)
pruneOffscreenRows()
currentToast?.dismissToast(animated: false)
}
class func refreshCommandTitle() -> String {
@ -111,13 +112,26 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
state = .loadingInitial
loadInitialItems() { result in
guard case let .success(snapshot) = result else {
self.state = .unloaded
return
}
DispatchQueue.main.async {
switch result {
case let .success(snapshot):
self.dataSource.apply(snapshot, animatingDifferences: false)
self.state = .loaded
case let .failure(.client(error)):
self.state = .unloaded
var config = ToastConfiguration(title: "Error Loading")
config.subtitle = error.localizedDescription
config.systemImageName = error.systemImageName
config.actionTitle = "Retry"
config.action = { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.loadInitial()
}
self.showToast(configuration: config, animated: true)
default:
self.state = .unloaded
}
}
}
}
@ -133,13 +147,27 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
state = .loadingOlder
loadOlderItems(currentSnapshot: dataSource.snapshot()) { result in
guard case let .success(snapshot) = result else {
self.state = .loaded
return
}
DispatchQueue.main.async {
self.dataSource.apply(snapshot, animatingDifferences: false)
self.state = .loaded
switch result {
case let .success(snapshot):
self.dataSource.apply(snapshot, animatingDifferences: false)
case let .failure(.client(error)):
var config = ToastConfiguration(title: "Error Loading Older")
config.subtitle = error.localizedDescription
config.systemImageName = error.systemImageName
config.actionTitle = "Retry"
config.action = { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.loadOlder()
}
self.showToast(configuration: config, animated: true)
default:
break
}
}
}
}
@ -196,24 +224,33 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
}
loadNewerItems(currentSnapshot: snapshot) { result in
guard case let .success(snapshot) = result else {
DispatchQueue.main.async {
self.refreshControl?.endRefreshing()
self.state = .loaded
}
return
}
DispatchQueue.main.async {
self.refreshControl?.endRefreshing()
switch result {
case let .success(snapshot):
self.dataSource.apply(snapshot, animatingDifferences: false)
self.state = .loaded
if let item = item,
let indexPath = self.dataSource.indexPath(for: item) {
// maintain the current position in the list (don't scroll to top)
self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
}
case let .failure(.client(error)):
var config = ToastConfiguration(title: "Error Loading Newer")
config.subtitle = error.localizedDescription
config.systemImageName = error.systemImageName
config.actionTitle = "Retry"
config.action = { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.refresh()
}
self.showToast(configuration: config, animated: true)
default:
break
}
}
}
}
@ -267,5 +304,20 @@ extension DiffableTimelineLikeTableViewController {
extension DiffableTimelineLikeTableViewController: BackgroundableViewController {
func sceneDidEnterBackground() {
pruneOffscreenRows()
currentToast?.dismissToast(animated: false)
}
}
extension DiffableTimelineLikeTableViewController: ToastableViewController {
}
fileprivate extension Client.Error {
var systemImageName: String {
switch self {
case .networkError(_):
return "wifi.exclamationmark"
default:
return "exclamationmark.triangle"
}
}
}

View File

@ -0,0 +1,31 @@
//
// ToastConfiguration.swift
// ToastConfiguration
//
// Created by Shadowfacts on 8/14/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
struct ToastConfiguration {
var systemImageName: String?
var title: String
var subtitle: String?
var actionTitle: String?
var action: ((ToastView) -> Void)?
var edgeSpacing: CGFloat = 8
var edge: Edge = .automatic
var dismissOnScroll = true
init(title: String) {
self.title = title
}
enum Edge: Equatable {
case top
case bottom
/// Determines edge based on the current device. Bottom on iPhone, top on iPad/Mac.
case automatic
}
}

View File

@ -0,0 +1,223 @@
//
// ToastView.swift
// ToastView
//
// Created by Shadowfacts on 8/14/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
class ToastView: UIView {
let configuration: ToastConfiguration
private var shrinkAnimator: UIViewPropertyAnimator?
private var recognizedGesture = false
private var offscreenTranslation: CGFloat {
var translation = bounds.height + configuration.edgeSpacing
if configuration.edge == .bottom {
translation += superview?.safeAreaInsets.bottom ?? 0
} else {
translation += superview?.safeAreaInsets.top ?? 0
translation *= -1
}
return translation
}
init(configuration: ToastConfiguration) {
precondition(configuration.edge != .automatic)
self.configuration = configuration
super.init(frame: .zero)
setupView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupView() {
backgroundColor = .systemBlue
layer.shadowColor = UIColor.black.cgColor
layer.shadowRadius = 5
layer.shadowOffset = CGSize(width: 0, height: 2.5)
layer.shadowOpacity = 0.5
layer.masksToBounds = false
let stack = UIStackView()
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .horizontal
stack.spacing = 8
if let name = configuration.systemImageName {
let imageView = UIImageView(image: UIImage(systemName: name))
imageView.tintColor = .white
imageView.contentMode = .scaleAspectFit
stack.addArrangedSubview(imageView)
}
let titleLabel = UILabel()
titleLabel.text = configuration.title
titleLabel.textColor = .white
titleLabel.font = .boldSystemFont(ofSize: 14)
titleLabel.adjustsFontSizeToFitWidth = true
if let subtitle = configuration.subtitle {
let subtitleLabel = UILabel()
subtitleLabel.text = subtitle
subtitleLabel.textColor = .white
subtitleLabel.numberOfLines = 0
subtitleLabel.font = .systemFont(ofSize: 14)
let vStack = UIStackView(arrangedSubviews: [
titleLabel,
subtitleLabel
])
vStack.axis = .vertical
vStack.spacing = 4
stack.addArrangedSubview(vStack)
} else {
stack.addArrangedSubview(titleLabel)
}
if let actionTitle = configuration.actionTitle {
let actionLabel = UILabel()
actionLabel.text = actionTitle
actionLabel.font = .boldSystemFont(ofSize: 16)
actionLabel.textColor = .white
stack.addArrangedSubview(actionLabel)
}
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalToSystemSpacingAfter: leadingAnchor, multiplier: 1),
trailingAnchor.constraint(equalToSystemSpacingAfter: stack.trailingAnchor, multiplier: 1),
stack.topAnchor.constraint(equalTo: topAnchor, constant: 4),
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
])
let pan = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
addGestureRecognizer(pan)
}
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = min(32, bounds.height / 2)
layer.shadowPath = CGPath(roundedRect: bounds, cornerWidth: layer.cornerRadius, cornerHeight: layer.cornerRadius, transform: nil)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
recognizedGesture = false
shrinkAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut) {
self.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
}
shrinkAnimator?.startAnimation(afterDelay: 0.1)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
if !recognizedGesture {
guard let shrinkAnimator = shrinkAnimator else {
return
}
shrinkAnimator.stopAnimation(true)
UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseInOut) {
self.transform = .identity
}
configuration.action?(self)
}
shrinkAnimator = nil
}
@objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translation(in: self).y
let isDraggingAwayFromDismissalEdge = (configuration.edge == .top && translation > 0) || (configuration.edge == .bottom && translation < 0)
switch recognizer.state {
case .began:
recognizedGesture = true
UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
self.transform = .identity
}
break
case .changed:
var distance: CGFloat
if isDraggingAwayFromDismissalEdge {
distance = sqrt(abs(translation))
if configuration.edge == .bottom {
distance *= -1
}
} else {
distance = translation
}
transform = CGAffineTransform(translationX: 0, y: distance)
case .ended, .cancelled:
let velocity = recognizer.velocity(in: self).y
let distance = isDraggingAwayFromDismissalEdge ? sqrt(abs(translation)) : translation
let minDismissalDistance = configuration.edgeSpacing + bounds.height / 2
let dismissDueToDistance = configuration.edge == .bottom ? distance > minDismissalDistance : -distance > minDismissalDistance
let minDismissalVelocity: CGFloat = 250
let dismissDueToVelocity = configuration.edge == .bottom ? velocity > minDismissalDistance : velocity < -minDismissalVelocity
if dismissDueToDistance || dismissDueToVelocity {
if abs(translation) < abs(offscreenTranslation) {
let distanceLeft = offscreenTranslation - translation
let duration = 1 / TimeInterval(max(velocity, minDismissalVelocity) / distanceLeft)
UIView.animate(withDuration: duration, delay: 0, options: .allowUserInteraction) {
self.transform = CGAffineTransform(translationX: 0, y: self.offscreenTranslation)
} completion: { (_) in
self.removeFromSuperview()
}
} else {
self.removeFromSuperview()
}
} else {
let duration = 0.5
let velocity = distance * duration
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: velocity, options: .allowUserInteraction) {
self.transform = .identity
}
}
default:
break
}
}
func dismissToast(animated: Bool) {
guard animated else {
removeFromSuperview()
return
}
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) {
self.transform = CGAffineTransform(translationX: 0, y: self.offscreenTranslation)
} completion: { (_) in
self.removeFromSuperview()
}
}
func animateAppearance() {
self.transform = CGAffineTransform(translationX: 0, y: offscreenTranslation)
let duration = 0.5
let velocity = 0.5
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: velocity, options: []) {
self.transform = .identity
}
}
}

View File

@ -0,0 +1,91 @@
//
// ToastableViewController.swift
// ToastableViewController
//
// Created by Shadowfacts on 8/14/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
protocol ToastableViewController: UIViewController {
var toastParentView: UIView { get }
}
private var currentToastKey = "Tusker_currentToast"
extension ToastableViewController {
private(set) var currentToast: ToastView? {
get {
let holder = objc_getAssociatedObject(self, &currentToastKey) as? WeakHolder<ToastView>
return holder?.object
}
set {
if let newValue = newValue {
objc_setAssociatedObject(self, &currentToastKey, WeakHolder(object: newValue), .OBJC_ASSOCIATION_RETAIN)
} else {
objc_setAssociatedObject(self, &currentToastKey, nil, .OBJC_ASSOCIATION_RETAIN)
}
}
}
var toastParentView: UIView { view }
func showToast(configuration config: ToastConfiguration, animated: Bool) {
currentToast?.dismissToast(animated: false)
var config = config
config.edge = effectiveEdge(edge: config.edge)
let toast = ToastView(configuration: config)
currentToast = toast
let parentSafeArea = toastParentView.safeAreaLayoutGuide
toast.translatesAutoresizingMaskIntoConstraints = false
toastParentView.addSubview(toast)
let yConstraint: NSLayoutConstraint
switch config.edge {
case .top:
yConstraint = toast.topAnchor.constraint(equalTo: parentSafeArea.topAnchor, constant: config.edgeSpacing)
case .bottom:
yConstraint = parentSafeArea.bottomAnchor.constraint(equalTo: toast.bottomAnchor, constant: config.edgeSpacing)
case .automatic:
fatalError("unreachable")
}
NSLayoutConstraint.activate([
toast.leadingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: parentSafeArea.leadingAnchor, multiplier: 1),
parentSafeArea.trailingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: toast.trailingAnchor, multiplier: 1),
parentSafeArea.centerXAnchor.constraint(equalTo: toast.centerXAnchor),
yConstraint,
])
if animated {
toast.animateAppearance()
}
}
private func effectiveEdge(edge: ToastConfiguration.Edge) -> ToastConfiguration.Edge {
guard case .automatic = edge else {
return edge
}
if UIDevice.current.userInterfaceIdiom == .phone {
return .bottom
} else {
return .top
}
}
}
fileprivate class WeakHolder<T: AnyObject> {
weak var object: T?
init(object: T) {
self.object = object
}
}