From 363424fa745d411bd0d4d19d066d57d1edb5af9d Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Wed, 5 Jan 2022 17:25:57 +0100 Subject: [PATCH] Add pull to refresh for Subscriptions, Popular and Trending (fixes #31) --- Shared/Favorites/FavoritesView.swift | 9 ++ Shared/Playlists/PlaylistsView.swift | 3 + Shared/Trending/TrendingView.swift | 132 ++++++++++-------- Shared/Views/PopularView.swift | 8 ++ Shared/Views/SubscriptionsView.swift | 28 +++- .../Extensions/UIResponder+Extensions.swift | 15 ++ .../Extensions/UIView+Extensions.swift | 70 ++++++++++ Vendor/RefreshControl/README | 47 +++++++ Vendor/RefreshControl/RefreshControl.swift | 56 ++++++++ .../RefreshControlModifier.swift | 42 ++++++ .../FramePreferenceKey.swift | 18 +++ .../ScrollViewMatcher/ScrollViewMatcher.swift | 106 ++++++++++++++ Yattee.xcodeproj/project.pbxproj | 60 ++++++++ 13 files changed, 527 insertions(+), 67 deletions(-) create mode 100644 Vendor/RefreshControl/Extensions/UIResponder+Extensions.swift create mode 100644 Vendor/RefreshControl/Extensions/UIView+Extensions.swift create mode 100644 Vendor/RefreshControl/README create mode 100644 Vendor/RefreshControl/RefreshControl.swift create mode 100644 Vendor/RefreshControl/RefreshControlModifier.swift create mode 100644 Vendor/RefreshControl/ScrollViewMatcher/FramePreferenceKey.swift create mode 100644 Vendor/RefreshControl/ScrollViewMatcher/ScrollViewMatcher.swift diff --git a/Shared/Favorites/FavoritesView.swift b/Shared/Favorites/FavoritesView.swift index 48460598..31425a7a 100644 --- a/Shared/Favorites/FavoritesView.swift +++ b/Shared/Favorites/FavoritesView.swift @@ -27,11 +27,17 @@ struct FavoritesView: View { FavoriteItemView(item: item, dragging: $dragging) } #else + #if os(iOS) + let first = favorites.first + #endif ForEach(favorites) { item in FavoriteItemView(item: item, dragging: $dragging) #if os(macOS) .workaroundForVerticalScrollingBug() #endif + #if os(iOS) + .padding(.top, item == first && RefreshControl.navigationBarTitleDisplayMode == .inline ? 10 : 0) + #endif } #endif } @@ -54,6 +60,9 @@ struct FavoritesView: View { .background(Color.secondaryBackground) .frame(minWidth: 360) #endif + #if os(iOS) + .navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode) + #endif } } } diff --git a/Shared/Playlists/PlaylistsView.swift b/Shared/Playlists/PlaylistsView.swift index a6fad728..d5bec8d8 100644 --- a/Shared/Playlists/PlaylistsView.swift +++ b/Shared/Playlists/PlaylistsView.swift @@ -127,6 +127,9 @@ struct PlaylistsView: View { .onChange(of: accounts.current) { _ in model.load(force: true) } + #if os(iOS) + .navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode) + #endif } #if os(tvOS) diff --git a/Shared/Trending/TrendingView.swift b/Shared/Trending/TrendingView.swift index a9dd405c..ae33edb4 100644 --- a/Shared/Trending/TrendingView.swift +++ b/Shared/Trending/TrendingView.swift @@ -48,6 +48,69 @@ struct TrendingView: View { } } } + + .toolbar { + #if os(macOS) + ToolbarItemGroup { + if let favoriteItem = favoriteItem { + FavoriteButton(item: favoriteItem) + .id(favoriteItem.id) + } + + if accounts.app.supportsTrendingCategories { + categoryButton + } + countryButton + } + #elseif os(iOS) + ToolbarItemGroup(placement: .bottomBar) { + Group { + if accounts.app.supportsTrendingCategories { + HStack { + Text("Category") + .foregroundColor(.secondary) + + categoryButton + // only way to disable Menu animation is to + // force redraw of the view when it changes + .id(UUID()) + } + + Spacer() + } + + if let favoriteItem = favoriteItem { + FavoriteButton(item: favoriteItem) + .id(favoriteItem.id) + + Spacer() + } + + HStack { + Text("Country") + .foregroundColor(.secondary) + + countryButton + } + } + } + #endif + } + .onChange(of: resource) { _ in + resource.load() + updateFavoriteItem() + } + .onAppear { + if videos.isEmpty { + resource.addObserver(store) + resource.loadIfNeeded() + } else { + store.replace(videos) + } + + updateFavoriteItem() + } + #if os(tvOS) .fullScreenCover(isPresented: $presentingCountrySelection) { TrendingCountry(selectedCountry: $country) @@ -61,67 +124,14 @@ struct TrendingView: View { } .navigationTitle("Trending") #endif - .toolbar { - #if os(macOS) - ToolbarItemGroup { - if let favoriteItem = favoriteItem { - FavoriteButton(item: favoriteItem) - .id(favoriteItem.id) - } - - if accounts.app.supportsTrendingCategories { - categoryButton - } - countryButton - } - #elseif os(iOS) - ToolbarItemGroup(placement: .bottomBar) { - Group { - HStack { - if accounts.app.supportsTrendingCategories { - Text("Category") - .foregroundColor(.secondary) - - categoryButton - // only way to disable Menu animation is to - // force redraw of the view when it changes - .id(UUID()) - } - } - - Spacer() - - if let favoriteItem = favoriteItem { - FavoriteButton(item: favoriteItem) - .id(favoriteItem.id) - - Spacer() - } - - HStack { - Text("Country") - .foregroundColor(.secondary) - - countryButton - } - } - } - #endif - } - .onChange(of: resource) { _ in - resource.load() - updateFavoriteItem() - } - .onAppear { - if videos.isEmpty { - resource.addObserver(store) - resource.loadIfNeeded() - } else { - store.replace(videos) - } - - updateFavoriteItem() - } + #if os(iOS) + .refreshControl { refreshControl in + resource.load().onCompletion { _ in + refreshControl.endRefreshing() + } + } + .navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode) + #endif } #if os(tvOS) diff --git a/Shared/Views/PopularView.swift b/Shared/Views/PopularView.swift index f5296497..42a0974c 100644 --- a/Shared/Views/PopularView.swift +++ b/Shared/Views/PopularView.swift @@ -30,5 +30,13 @@ struct PopularView: View { FavoriteButton(item: FavoriteItem(section: .popular)) } } + #if os(iOS) + .refreshControl { refreshControl in + resource?.load().onCompletion { _ in + refreshControl.endRefreshing() + } + } + .navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode) + #endif } } diff --git a/Shared/Views/SubscriptionsView.swift b/Shared/Views/SubscriptionsView.swift index 31b5dff0..50107f44 100644 --- a/Shared/Views/SubscriptionsView.swift +++ b/Shared/Views/SubscriptionsView.swift @@ -24,6 +24,13 @@ struct SubscriptionsView: View { .onChange(of: accounts.current) { _ in loadResources(force: true) } + #if os(iOS) + .refreshControl { refreshControl in + loadResources(force: true) { + refreshControl.endRefreshing() + } + } + #endif } } .toolbar { @@ -31,26 +38,35 @@ struct SubscriptionsView: View { FavoriteButton(item: FavoriteItem(section: .subscriptions)) } } + #if os(iOS) + .navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode) + #endif } - fileprivate func loadResources(force: Bool = false) { + private func loadResources(force: Bool = false, onCompletion: @escaping () -> Void = {}) { feed?.addObserver(store) if accounts.app == .invidious { // Invidious for some reason won't refresh feed until homepage is loaded if let request = force ? accounts.api.home?.load() : accounts.api.home?.loadIfNeeded() { request.onSuccess { _ in - loadFeed(force: force) + loadFeed(force: force, onCompletion: onCompletion) } } else { - loadFeed(force: force) + loadFeed(force: force, onCompletion: onCompletion) } } else { - loadFeed(force: force) + loadFeed(force: force, onCompletion: onCompletion) } } - fileprivate func loadFeed(force: Bool = false) { - _ = force ? feed?.load() : feed?.loadIfNeeded() + private func loadFeed(force: Bool = false, onCompletion: @escaping () -> Void = {}) { + if let request = force ? feed?.load() : feed?.loadIfNeeded() { + request.onCompletion { _ in + onCompletion() + } + } else { + onCompletion() + } } } diff --git a/Vendor/RefreshControl/Extensions/UIResponder+Extensions.swift b/Vendor/RefreshControl/Extensions/UIResponder+Extensions.swift new file mode 100644 index 00000000..ae87e27d --- /dev/null +++ b/Vendor/RefreshControl/Extensions/UIResponder+Extensions.swift @@ -0,0 +1,15 @@ +// +// UIResponder+Extensions.swift +// SwiftUI_Pull_to_Refresh +// +// Created by Geri Borbás on 21/09/2021. +// + +import Foundation +import UIKit + +extension UIResponder { + var parentViewController: UIViewController? { + next as? UIViewController ?? next?.parentViewController + } +} diff --git a/Vendor/RefreshControl/Extensions/UIView+Extensions.swift b/Vendor/RefreshControl/Extensions/UIView+Extensions.swift new file mode 100644 index 00000000..dcd8bb34 --- /dev/null +++ b/Vendor/RefreshControl/Extensions/UIView+Extensions.swift @@ -0,0 +1,70 @@ +// +// UIView+Extensions.swift +// SwiftUI_Pull_to_Refresh +// +// Created by Geri Borbás on 19/09/2021. +// + +import Foundation +import UIKit + +extension UIView { + /// Returs frame in screen coordinates. + var globalFrame: CGRect { + if let window = window { + return convert(bounds, to: window.screen.coordinateSpace) + } else { + return .zero + } + } + + /// Returns with all the instances of the given view type in the view hierarchy. + func viewsInHierarchy() -> [ViewType]? { + var views: [ViewType] = [] + viewsInHierarchy(views: &views) + return views.isEmpty ? nil : views + } + + private func viewsInHierarchy(views: inout [ViewType]) { + subviews.forEach { eachSubView in + if let matchingView = eachSubView as? ViewType { + views.append(matchingView) + } + eachSubView.viewsInHierarchy(views: &views) + } + } + + /// Search ancestral view hierarcy for the given view type. + func searchViewAnchestorsFor( + _ onViewFound: (ViewType) -> Void + ) { + if let matchingView = superview as? ViewType { + onViewFound(matchingView) + } else { + superview?.searchViewAnchestorsFor(onViewFound) + } + } + + /// Search ancestral view hierarcy for the given view type. + func searchViewAnchestorsFor() -> ViewType? { + if let matchingView = superview as? ViewType { + return matchingView + } else { + return superview?.searchViewAnchestorsFor() + } + } + + func printViewHierarchyInformation(_ level: Int = 0) { + printViewInformation(level) + subviews.forEach { $0.printViewHierarchyInformation(level + 1) } + } + + func printViewInformation(_ level: Int) { + let leadingWhitespace = String(repeating: " ", count: level) + let className = "\(Self.self)" + let superclassName = "\(superclass!)" + let frame = "\(self.frame)" + let id = (accessibilityIdentifier == nil) ? "" : " `\(accessibilityIdentifier!)`" + print("\(leadingWhitespace)\(className): \(superclassName)\(id) \(frame)") + } +} diff --git a/Vendor/RefreshControl/README b/Vendor/RefreshControl/README new file mode 100644 index 00000000..ccea9eec --- /dev/null +++ b/Vendor/RefreshControl/README @@ -0,0 +1,47 @@ +https://github.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh + +# SwiftUI Pull to Refresh +⇣ SwiftUI Pull to Refresh (for iOS 13 and iOS 14) condensed into a single modifier. + + +Complementary repository for article [**SwiftUI Pull to Refresh**] (in progress). See [`ContentView.swift`] for usage, and [`RefreshControlModifier.swift`] for the source. Designed to work with **multiple scroll views** on the same screen. + +```Swift +struct ContentView: View { + + var body: some View { + VStack { + HStack { + List { + ForEach(1...100, id: \.self) { eachRowIndex in + Text("Left \(eachRowIndex)") + } + } + .refreshControl { refreshControl in + Network.refresh { + refreshControl.endRefreshing() + } + } + List { + ForEach(1...100, id: \.self) { eachRowIndex in + Text("Right \(eachRowIndex)") + } + } + .refreshControl { refreshControl in + Network.refresh { + refreshControl.endRefreshing() + } + } + } + } + } +} +``` + + +## License + +> Licensed under the [**MIT License**](https://en.wikipedia.org/wiki/MIT_License). + +[`ContentView.swift`]: SwiftUI_Pull_to_Refresh/Views/ContentView.swift +[`RefreshControl.swift`]: SwiftUI_Pull_to_Refresh/Views/RefreshControl.swift diff --git a/Vendor/RefreshControl/RefreshControl.swift b/Vendor/RefreshControl/RefreshControl.swift new file mode 100644 index 00000000..67a8b3e1 --- /dev/null +++ b/Vendor/RefreshControl/RefreshControl.swift @@ -0,0 +1,56 @@ +// +// RefreshControl.swift +// SwiftUI_Pull_to_Refresh +// +// Created by Geri Borbás on 18/09/2021. +// + +import Combine +import Foundation +import SwiftUI +import UIKit + +final class RefreshControl: ObservableObject { + static var navigationBarTitleDisplayMode: NavigationBarItem.TitleDisplayMode { + if #available(iOS 15.0, *) { + return .automatic + } + + return .inline + } + + let onValueChanged: (_ refreshControl: UIRefreshControl) -> Void + + internal init(onValueChanged: @escaping ((UIRefreshControl) -> Void)) { + self.onValueChanged = onValueChanged + } + + /// Adds a `UIRefreshControl` to the `UIScrollView` provided. + func add(to scrollView: UIScrollView) { + scrollView.refreshControl = UIRefreshControl().withTarget( + self, + action: #selector(onValueChangedAction), + for: .valueChanged + ) + .testable(as: "RefreshControl") + } + + @objc private func onValueChangedAction(sender: UIRefreshControl) { + onValueChanged(sender) + } +} + +extension UIRefreshControl { + /// Convinience method to assign target action inline. + func withTarget(_ target: Any?, action: Selector, for controlEvents: UIControl.Event) -> UIRefreshControl { + addTarget(target, action: action, for: controlEvents) + return self + } + + /// Convinience method to match refresh control for UI testing. + func testable(as id: String) -> UIRefreshControl { + isAccessibilityElement = true + accessibilityIdentifier = id + return self + } +} diff --git a/Vendor/RefreshControl/RefreshControlModifier.swift b/Vendor/RefreshControl/RefreshControlModifier.swift new file mode 100644 index 00000000..528a5300 --- /dev/null +++ b/Vendor/RefreshControl/RefreshControlModifier.swift @@ -0,0 +1,42 @@ +// +// RefreshControlModifier.swift +// SwiftUI_Pull_to_Refresh +// +// Created by Geri Borbás on 18/09/2021. +// + +import Foundation +import SwiftUI + +struct RefreshControlModifier: ViewModifier { + @State private var geometryReaderFrame: CGRect = .zero + let refreshControl: RefreshControl + + internal init(onValueChanged: @escaping (UIRefreshControl) -> Void) { + refreshControl = RefreshControl(onValueChanged: onValueChanged) + } + + func body(content: Content) -> some View { + content + .background( + GeometryReader { geometry in + ScrollViewMatcher( + onResolve: { scrollView in + refreshControl.add(to: scrollView) + }, + geometryReaderFrame: $geometryReaderFrame + ) + .preference(key: FramePreferenceKey.self, value: geometry.frame(in: .global)) + .onPreferenceChange(FramePreferenceKey.self) { frame in + self.geometryReaderFrame = frame + } + } + ) + } +} + +extension View { + func refreshControl(onValueChanged: @escaping (_ refreshControl: UIRefreshControl) -> Void) -> some View { + modifier(RefreshControlModifier(onValueChanged: onValueChanged)) + } +} diff --git a/Vendor/RefreshControl/ScrollViewMatcher/FramePreferenceKey.swift b/Vendor/RefreshControl/ScrollViewMatcher/FramePreferenceKey.swift new file mode 100644 index 00000000..65f05cbc --- /dev/null +++ b/Vendor/RefreshControl/ScrollViewMatcher/FramePreferenceKey.swift @@ -0,0 +1,18 @@ +// +// FramePreferenceKey.swift +// SwiftUI_Pull_to_Refresh +// +// Created by Geri Borbás on 21/09/2021. +// + +import Foundation +import SwiftUI + +struct FramePreferenceKey: PreferenceKey { + typealias Value = CGRect + static var defaultValue = CGRect.zero + + static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + value = nextValue() + } +} diff --git a/Vendor/RefreshControl/ScrollViewMatcher/ScrollViewMatcher.swift b/Vendor/RefreshControl/ScrollViewMatcher/ScrollViewMatcher.swift new file mode 100644 index 00000000..d2e3d0b5 --- /dev/null +++ b/Vendor/RefreshControl/ScrollViewMatcher/ScrollViewMatcher.swift @@ -0,0 +1,106 @@ +// +// ScrollViewMatcher.swift +// SwiftUI_Pull_to_Refresh +// +// Created by Geri Borbás on 17/09/2021. +// + +import Foundation +import SwiftUI + +final class ScrollViewMatcher: UIViewControllerRepresentable { + let onMatch: (UIScrollView) -> Void + @Binding var geometryReaderFrame: CGRect + + init(onResolve: @escaping (UIScrollView) -> Void, geometryReaderFrame: Binding) { + onMatch = onResolve + _geometryReaderFrame = geometryReaderFrame + } + + func makeUIViewController(context _: Context) -> ScrollViewMatcherViewController { + ScrollViewMatcherViewController(onResolve: onMatch, geometryReaderFrame: geometryReaderFrame) + } + + func updateUIViewController(_ viewController: ScrollViewMatcherViewController, context _: Context) { + viewController.geometryReaderFrame = geometryReaderFrame + } +} + +final class ScrollViewMatcherViewController: UIViewController { + let onMatch: (UIScrollView) -> Void + private var scrollView: UIScrollView? { + didSet { + if oldValue != scrollView, + let scrollView = scrollView + { + onMatch(scrollView) + } + } + } + + var geometryReaderFrame: CGRect { + didSet { + match() + } + } + + init(onResolve: @escaping (UIScrollView) -> Void, geometryReaderFrame: CGRect, debug _: Bool = false) { + onMatch = onResolve + self.geometryReaderFrame = geometryReaderFrame + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("Use init(onMatch:) to instantiate ScrollViewMatcherViewController.") + } + + func match() { + // matchUsingHierarchy() + matchUsingGeometry() + } + + func matchUsingHierarchy() { + if parent != nil { + // Lookup view ancestry for any `UIScrollView`. + view.searchViewAnchestorsFor { (scrollView: UIScrollView) in + self.scrollView = scrollView + } + } + } + + func matchUsingGeometry() { + if let parent = parent { + if let scrollViewsInHierarchy: [UIScrollView] = parent.view.viewsInHierarchy() { + // Return first match if only a single scroll view is found in the hierarchy. + if scrollViewsInHierarchy.count == 1, + let firstScrollViewInHierarchy = scrollViewsInHierarchy.first + { + scrollView = firstScrollViewInHierarchy + + // Filter by frame origins if multiple matches found. + } else { + if let firstMatchingFrameOrigin = scrollViewsInHierarchy.filter({ + $0.globalFrame.origin.close(to: geometryReaderFrame.origin) + }).first { + scrollView = firstMatchingFrameOrigin + } + } + } + } + } + + override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + match() + } +} + +extension CGPoint { + /// Returns `true` if this point is close the other point (considering a ~1 pt tolerance). + func close(to point: CGPoint) -> Bool { + let inset = Double(1) + let rect = CGRect(x: x - inset, y: y - inset, width: inset * 2, height: inset * 2) + return rect.contains(point) + } +} diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index 28697951..6b763594 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -515,6 +515,14 @@ 37DD9DA32785BBC900539416 /* NoCommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD9DA22785BBC900539416 /* NoCommentsView.swift */; }; 37DD9DA42785BBC900539416 /* NoCommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD9DA22785BBC900539416 /* NoCommentsView.swift */; }; 37DD9DA52785BBC900539416 /* NoCommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD9DA22785BBC900539416 /* NoCommentsView.swift */; }; + 37DD9DB12785D58D00539416 /* RefreshControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD9DAF2785D58D00539416 /* RefreshControl.swift */; }; + 37DD9DB42785D58D00539416 /* RefreshControlModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD9DB02785D58D00539416 /* RefreshControlModifier.swift */; }; + 37DD9DBA2785D60300539416 /* FramePreferenceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD9DB82785D60200539416 /* FramePreferenceKey.swift */; }; + 37DD9DBB2785D60300539416 /* FramePreferenceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD9DB82785D60200539416 /* FramePreferenceKey.swift */; }; + 37DD9DBC2785D60300539416 /* FramePreferenceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD9DB82785D60200539416 /* FramePreferenceKey.swift */; }; + 37DD9DBD2785D60300539416 /* ScrollViewMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD9DB92785D60200539416 /* ScrollViewMatcher.swift */; }; + 37DD9DC62785D63A00539416 /* UIResponder+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD9DC22785D63A00539416 /* UIResponder+Extensions.swift */; }; + 37DD9DCB2785E28C00539416 /* UIView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD9DC12785D63A00539416 /* UIView+Extensions.swift */; }; 37E04C0F275940FB00172673 /* VerticalScrollingFix.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E04C0E275940FB00172673 /* VerticalScrollingFix.swift */; }; 37E084AC2753D95F00039B7D /* AccountsNavigationLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E084AB2753D95F00039B7D /* AccountsNavigationLink.swift */; }; 37E084AD2753D95F00039B7D /* AccountsNavigationLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E084AB2753D95F00039B7D /* AccountsNavigationLink.swift */; }; @@ -778,6 +786,12 @@ 37D9169A27388A81002B1BAA /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStreams.swift; sourceTree = ""; }; 37DD9DA22785BBC900539416 /* NoCommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoCommentsView.swift; sourceTree = ""; }; + 37DD9DAF2785D58D00539416 /* RefreshControl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RefreshControl.swift; sourceTree = ""; }; + 37DD9DB02785D58D00539416 /* RefreshControlModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RefreshControlModifier.swift; sourceTree = ""; }; + 37DD9DB82785D60200539416 /* FramePreferenceKey.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramePreferenceKey.swift; sourceTree = ""; }; + 37DD9DB92785D60200539416 /* ScrollViewMatcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollViewMatcher.swift; sourceTree = ""; }; + 37DD9DC12785D63A00539416 /* UIView+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+Extensions.swift"; sourceTree = ""; }; + 37DD9DC22785D63A00539416 /* UIResponder+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIResponder+Extensions.swift"; sourceTree = ""; }; 37E04C0E275940FB00172673 /* VerticalScrollingFix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalScrollingFix.swift; sourceTree = ""; }; 37E084AB2753D95F00039B7D /* AccountsNavigationLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsNavigationLink.swift; sourceTree = ""; }; 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsView.swift; sourceTree = ""; }; @@ -1204,6 +1218,7 @@ 3722AEBA274DA312005EA4D6 /* Backports */, 37D4B1B72672CFE300C925CA /* Model */, 37C7A9022679058300E721B4 /* Extensions */, + 37DD9DCC2785EE6F00539416 /* Vendor */, 3748186426A762300084E870 /* Fixtures */, 37A3B15827255E7F000FB5EE /* Open in Yattee */, 377FC7D1267A080300A6BBAF /* Frameworks */, @@ -1346,6 +1361,43 @@ path = Gestures; sourceTree = ""; }; + 37DD9DAE2785D58D00539416 /* RefreshControl */ = { + isa = PBXGroup; + children = ( + 37DD9DC02785D63A00539416 /* Extensions */, + 37DD9DB72785D60200539416 /* ScrollViewMatcher */, + 37DD9DAF2785D58D00539416 /* RefreshControl.swift */, + 37DD9DB02785D58D00539416 /* RefreshControlModifier.swift */, + ); + path = RefreshControl; + sourceTree = ""; + }; + 37DD9DB72785D60200539416 /* ScrollViewMatcher */ = { + isa = PBXGroup; + children = ( + 37DD9DB82785D60200539416 /* FramePreferenceKey.swift */, + 37DD9DB92785D60200539416 /* ScrollViewMatcher.swift */, + ); + path = ScrollViewMatcher; + sourceTree = ""; + }; + 37DD9DC02785D63A00539416 /* Extensions */ = { + isa = PBXGroup; + children = ( + 37DD9DC22785D63A00539416 /* UIResponder+Extensions.swift */, + 37DD9DC12785D63A00539416 /* UIView+Extensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 37DD9DCC2785EE6F00539416 /* Vendor */ = { + isa = PBXGroup; + children = ( + 37DD9DAE2785D58D00539416 /* RefreshControl */, + ); + path = Vendor; + sourceTree = ""; + }; 37FB283F2721B20800A57617 /* Search */ = { isa = PBXGroup; children = ( @@ -1846,6 +1898,7 @@ 37130A5B277657090033018A /* Yattee.xcdatamodeld in Sources */, 37152EEA26EFEB95004FB96D /* LazyView.swift in Sources */, 3761ABFD26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */, + 37DD9DCB2785E28C00539416 /* UIView+Extensions.swift in Sources */, 3782B94F27553A6700990149 /* SearchSuggestions.swift in Sources */, 378E50FF26FE8EEE00F49626 /* AccountsMenuView.swift in Sources */, 37169AA62729E2CC0011DE61 /* AccountsBridge.swift in Sources */, @@ -1864,6 +1917,7 @@ 37B81AF926D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */, 37C0698227260B2100F7F6CB /* ThumbnailsModel.swift in Sources */, 37BC50A82778A84700510953 /* HistorySettings.swift in Sources */, + 37DD9DB12785D58D00539416 /* RefreshControl.swift in Sources */, 37B4E805277D0AB4004BF56A /* Orientation.swift in Sources */, 37DD87C7271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */, 371B7E662759786B00D21217 /* Comment+Fixtures.swift in Sources */, @@ -1881,6 +1935,7 @@ 3751B4B227836902000B7DF4 /* SearchPage.swift in Sources */, 37CC3F4C270CFE1700608308 /* PlayerQueueView.swift in Sources */, 37FFC440272734C3009FFD26 /* Throttle.swift in Sources */, + 37DD9DB42785D58D00539416 /* RefreshControlModifier.swift in Sources */, 3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */, 378AE940274EDFB5006A4EE1 /* Tint+Backport.swift in Sources */, 376BE50927347B5F009AD608 /* SettingsHeader.swift in Sources */, @@ -1909,6 +1964,7 @@ 37BA794326DBA973002A0235 /* PlaylistsModel.swift in Sources */, 37BC50AC2778BCBA00510953 /* HistoryModel.swift in Sources */, 37AAF29026740715007FC770 /* Channel.swift in Sources */, + 37DD9DBA2785D60300539416 /* FramePreferenceKey.swift in Sources */, 3748186A26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */, 37B81AFF26D2CA3700675966 /* VideoDetails.swift in Sources */, 377FC7E5267A084E00A6BBAF /* SearchView.swift in Sources */, @@ -1939,9 +1995,11 @@ 37A9965A26D6F8CA006E3224 /* HorizontalCells.swift in Sources */, 37D526DE2720AC4400ED2F5E /* VideosAPI.swift in Sources */, 37484C2526FC83E000287258 /* InstanceForm.swift in Sources */, + 37DD9DBD2785D60300539416 /* ScrollViewMatcher.swift in Sources */, 37B767DB2677C3CA0098BAA8 /* PlayerModel.swift in Sources */, 3788AC2726F6840700F6BAA9 /* FavoriteItemView.swift in Sources */, 375DFB5826F9DA010013F468 /* InstancesModel.swift in Sources */, + 37DD9DC62785D63A00539416 /* UIResponder+Extensions.swift in Sources */, 37C3A24927235FAA0087A57A /* ChannelPlaylistCell.swift in Sources */, 373CFACB26966264003CB2C6 /* SearchQuery.swift in Sources */, 37141673267A8E10006CA35D /* Country.swift in Sources */, @@ -2012,6 +2070,7 @@ 374C053C2724614F009BDDBE /* PlayerTVMenu.swift in Sources */, 377FC7DD267A081A00A6BBAF /* PopularView.swift in Sources */, 3784CDE327772EE40055BBF2 /* Watch.swift in Sources */, + 37DD9DBB2785D60300539416 /* FramePreferenceKey.swift in Sources */, 375DFB5926F9DA010013F468 /* InstancesModel.swift in Sources */, 3705B183267B4E4900704544 /* TrendingCategory.swift in Sources */, 37FB285F272225E800A57617 /* ContentItemView.swift in Sources */, @@ -2216,6 +2275,7 @@ files = ( 37EAD871267B9ED100D9E01B /* Segment.swift in Sources */, 373C8FE6275B955100CB5936 /* CommentsPage.swift in Sources */, + 37DD9DBC2785D60300539416 /* FramePreferenceKey.swift in Sources */, 37CC3F52270D010D00608308 /* VideoBanner.swift in Sources */, 37F49BA526CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */, 376CD21826FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */,