diff --git a/Core/FeatureFlag.swift b/Core/FeatureFlag.swift index e7e9b9165b..71bd3176ef 100644 --- a/Core/FeatureFlag.swift +++ b/Core/FeatureFlag.swift @@ -35,13 +35,14 @@ public enum FeatureFlag: String { case networkProtectionWaitlistAccess case networkProtectionWaitlistActive case subscription + case swipeTabs case autoconsentOnByDefault } extension FeatureFlag: FeatureFlagSourceProviding { public var source: FeatureFlagSource { switch self { - case .debugMenu, .appTrackingProtection, .subscription: + case .debugMenu, .appTrackingProtection, .subscription, .swipeTabs: return .internalOnly case .sync: return .remoteReleasable(.subfeature(SyncSubfeature.level0ShowSync)) diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index a94d9a0693..49166d4662 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -499,6 +499,9 @@ extension Pixel { case syncDeleteAccountError case syncLoginExistingAccountError + case swipeTabsUsed + case swipeTabsUsedDaily + case bookmarksCleanupFailed case bookmarksCleanupAttemptedWhileSyncWasEnabled case favoritesCleanupFailed @@ -989,6 +992,8 @@ extension Pixel.Event { case .syncDeleteAccountError: return "m_d_sync_delete_account_error" case .syncLoginExistingAccountError: return "m_d_sync_login_existing_account_error" + case .swipeTabsUsed: return "m_swipe-tabs-used" + case .swipeTabsUsedDaily: return "m_swipe-tabs-used-daily" case .bookmarksCleanupFailed: return "m_d_bookmarks_cleanup_failed" case .bookmarksCleanupAttemptedWhileSyncWasEnabled: return "m_d_bookmarks_cleanup_attempted_while_sync_was_enabled" diff --git a/Core/UIViewExtension.swift b/Core/UIViewExtension.swift index eb12e52945..d1cea20ad8 100644 --- a/Core/UIViewExtension.swift +++ b/Core/UIViewExtension.swift @@ -73,4 +73,17 @@ extension UIView { view.removeFromSuperview() } } + + @MainActor + public func createImageSnapshot(inBounds bounds: CGRect? = nil) -> UIImage? { + let bounds = bounds ?? self.frame + let size = bounds.size + UIGraphicsBeginImageContextWithOptions(size, false, UIScreen.main.scale) + UIGraphicsGetCurrentContext()?.translateBy(x: -bounds.origin.x, y: -bounds.origin.y) + drawHierarchy(in: frame, afterScreenUpdates: true) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image + } + } diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 700b74eb7b..9dc0172c0a 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -459,6 +459,7 @@ 85AE668E2097206E0014CF04 /* NotificationView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 85AE668D2097206E0014CF04 /* NotificationView.xib */; }; 85AE6690209724120014CF04 /* NotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85AE668F209724120014CF04 /* NotificationView.swift */; }; 85AFA1212B45D14F0028A504 /* BookmarksMigrationAssertionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85AFA1202B45D14F0028A504 /* BookmarksMigrationAssertionTests.swift */; }; + 85B9814E2B5EB618009AC9A6 /* SwipeTabsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B9814D2B5EB618009AC9A6 /* SwipeTabsCoordinator.swift */; }; 85B9CB8921AEBDD5009001F1 /* FavoriteHomeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B9CB8821AEBDD5009001F1 /* FavoriteHomeCell.swift */; }; 85BA58551F34F49E00C6E8CA /* AppUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BA58541F34F49E00C6E8CA /* AppUserDefaults.swift */; }; 85BA58581F34F72F00C6E8CA /* AppUserDefaultsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BA58561F34F61C00C6E8CA /* AppUserDefaultsTests.swift */; }; @@ -491,6 +492,7 @@ 85DB12EB2A1FE2A4000A4A72 /* LockScreenWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85DB12EA2A1FE2A4000A4A72 /* LockScreenWidgets.swift */; }; 85DB12ED2A1FED0C000A4A72 /* AppDelegate+AppDeepLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85DB12EC2A1FED0C000A4A72 /* AppDelegate+AppDeepLinks.swift */; }; 85DDE0402AC6FF65006ABCA2 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85DDE03F2AC6FF65006ABCA2 /* MainView.swift */; }; + 85DE681A2B6A8BB000DED4FE /* MainViewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85DE68192B6A8BB000DED4FE /* MainViewCoordinator.swift */; }; 85DF714624F7FE6100C89288 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F143C2E41E4A4CD400CFDE3A /* Core.framework */; }; 85DFEDED24C7CCA500973FE7 /* AppWidthObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85DFEDEC24C7CCA500973FE7 /* AppWidthObserver.swift */; }; 85DFEDEF24C7EA3B00973FE7 /* SmallOmniBarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85DFEDEE24C7EA3B00973FE7 /* SmallOmniBarState.swift */; }; @@ -1545,6 +1547,7 @@ 85AE668D2097206E0014CF04 /* NotificationView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = NotificationView.xib; sourceTree = ""; }; 85AE668F209724120014CF04 /* NotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationView.swift; sourceTree = ""; }; 85AFA1202B45D14F0028A504 /* BookmarksMigrationAssertionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksMigrationAssertionTests.swift; sourceTree = ""; }; + 85B9814D2B5EB618009AC9A6 /* SwipeTabsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeTabsCoordinator.swift; sourceTree = ""; }; 85B9CB8821AEBDD5009001F1 /* FavoriteHomeCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteHomeCell.swift; sourceTree = ""; }; 85BA58541F34F49E00C6E8CA /* AppUserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppUserDefaults.swift; sourceTree = ""; }; 85BA58561F34F61C00C6E8CA /* AppUserDefaultsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppUserDefaultsTests.swift; sourceTree = ""; }; @@ -1578,6 +1581,7 @@ 85DB12EA2A1FE2A4000A4A72 /* LockScreenWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenWidgets.swift; sourceTree = ""; }; 85DB12EC2A1FED0C000A4A72 /* AppDelegate+AppDeepLinks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+AppDeepLinks.swift"; sourceTree = ""; }; 85DDE03F2AC6FF65006ABCA2 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; + 85DE68192B6A8BB000DED4FE /* MainViewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewCoordinator.swift; sourceTree = ""; }; 85DFEDEC24C7CCA500973FE7 /* AppWidthObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppWidthObserver.swift; sourceTree = ""; }; 85DFEDEE24C7EA3B00973FE7 /* SmallOmniBarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallOmniBarState.swift; sourceTree = ""; }; 85DFEDF024C7EEA400973FE7 /* LargeOmniBarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeOmniBarState.swift; sourceTree = ""; }; @@ -5390,6 +5394,8 @@ 85864FBB24D31EF300E756FF /* SuggestionTrayViewController.swift */, 851DFD86212C39D300D95F20 /* TabSwitcherButton.swift */, CBEFB9102ADFFE7900DEDE7B /* CriticalAlerts.swift */, + 85B9814D2B5EB618009AC9A6 /* SwipeTabsCoordinator.swift */, + 85DE68192B6A8BB000DED4FE /* MainViewCoordinator.swift */, ); name = Main; sourceTree = ""; @@ -6663,6 +6669,7 @@ 3151F0EE2735800800226F58 /* VoiceSearchFeedbackView.swift in Sources */, 857EEB752095FFAC008A005C /* HomeRowInstructionsViewController.swift in Sources */, 311BD1AF2836BB4200AEF6C1 /* AutofillItemsLockedView.swift in Sources */, + 85DE681A2B6A8BB000DED4FE /* MainViewCoordinator.swift in Sources */, 0290472A29E867800008FE3C /* AppTPTrackerDetailView.swift in Sources */, F1617C151E57336D00DEDCAF /* TabManager.swift in Sources */, 85449EF523FDA02800512AAF /* KeyboardSettingsViewController.swift in Sources */, @@ -6886,6 +6893,7 @@ 3151F0EA27357FBA00226F58 /* SpeechRecognizer.swift in Sources */, F17922E21E71CD67006E3D97 /* NoSuggestionsTableViewCell.swift in Sources */, 0290472229E723260008FE3C /* AppTPManageTrackerCell.swift in Sources */, + 85B9814E2B5EB618009AC9A6 /* SwipeTabsCoordinator.swift in Sources */, 985AAE4524899369007A43EC /* HomeScreenTransition.swift in Sources */, 85E58C2C28FDA94F006A801A /* FavoritesViewController.swift in Sources */, 1E8AD1CF27C000A000ABA377 /* CompleteDownloadRow.swift in Sources */, diff --git a/DuckDuckGo/MainView.swift b/DuckDuckGo/MainView.swift index 4d5171167e..06564f8329 100644 --- a/DuckDuckGo/MainView.swift +++ b/DuckDuckGo/MainView.swift @@ -19,9 +19,6 @@ import UIKit -// swiftlint:disable file_length -// swiftlint:disable line_length - class MainViewFactory { private let coordinator: MainViewCoordinator @@ -60,9 +57,11 @@ extension MainViewFactory { createNotificationBarContainer() createStatusBackground() createTabBarContainer() + createOmniBar() + createToolbar() createNavigationBarContainer() + createNavigationBarCollectionView() createProgressView() - createToolbar() } private func createProgressView() { @@ -70,12 +69,43 @@ extension MainViewFactory { superview.addSubview(coordinator.progress) } - final class NavigationBarContainer: UIView { } - private func createNavigationBarContainer() { + private func createOmniBar() { coordinator.omniBar = OmniBar.loadFromXib() coordinator.omniBar.translatesAutoresizingMaskIntoConstraints = false + } + + final class NavigationBarCollectionView: UICollectionView { + + var hitTestInsets = UIEdgeInsets.zero + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + return bounds.inset(by: hitTestInsets).contains(point) + } + + // Don't allow the use to drag the scrollbar or the UI will glitch. + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let view = super.hitTest(point, with: event) + if view == self.subviews.first(where: { $0 is UIImageView }) { + return nil + } + return view + } + } + + private func createNavigationBarCollectionView() { + // Layout is replaced elsewhere, but required to construct the view. + coordinator.navigationBarCollectionView = NavigationBarCollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + + // scrollview subclasses change the default to true, but we need this for the separator on the omnibar + coordinator.navigationBarCollectionView.clipsToBounds = false + + coordinator.navigationBarCollectionView.translatesAutoresizingMaskIntoConstraints = false + coordinator.navigationBarContainer.addSubview(coordinator.navigationBarCollectionView) + } + + final class NavigationBarContainer: UIView { } + private func createNavigationBarContainer() { coordinator.navigationBarContainer = NavigationBarContainer() - coordinator.navigationBarContainer.addSubview(coordinator.omniBar) superview.addSubview(coordinator.navigationBarContainer) } @@ -181,26 +211,27 @@ extension MainViewFactory { coordinator.constraints.progressBarTop, ]) } - + private func constrainNavigationBarContainer() { let navigationBarContainer = coordinator.navigationBarContainer! let toolbar = coordinator.toolbar! - let omniBar = coordinator.omniBar! + let navigationBarCollectionView = coordinator.navigationBarCollectionView! coordinator.constraints.navigationBarContainerTop = navigationBarContainer.constrainView(superview.safeAreaLayoutGuide, by: .top) coordinator.constraints.navigationBarContainerBottom = navigationBarContainer.constrainView(toolbar, by: .bottom, to: .top) - coordinator.constraints.omniBarBottom = omniBar.constrainView(navigationBarContainer, by: .bottom, relatedBy: .greaterThanOrEqual) - + coordinator.constraints.navigationBarCollectionViewBottom + = navigationBarCollectionView.constrainView(navigationBarContainer, by: .bottom, relatedBy: .greaterThanOrEqual) + NSLayoutConstraint.activate([ coordinator.constraints.navigationBarContainerTop, - navigationBarContainer.constrainView(superview, by: .centerX), - navigationBarContainer.constrainView(superview, by: .width), + navigationBarContainer.constrainView(superview, by: .leading), + navigationBarContainer.constrainView(superview, by: .trailing), navigationBarContainer.constrainAttribute(.height, to: 52, relatedBy: .greaterThanOrEqual), - omniBar.constrainAttribute(.height, to: 52), - omniBar.constrainView(navigationBarContainer, by: .top), - omniBar.constrainView(navigationBarContainer, by: .leading), - omniBar.constrainView(navigationBarContainer, by: .trailing), - coordinator.constraints.omniBarBottom, + navigationBarCollectionView.constrainAttribute(.height, to: 52), + navigationBarCollectionView.constrainView(navigationBarContainer, by: .top), + navigationBarCollectionView.constrainView(navigationBarContainer, by: .leading), + navigationBarCollectionView.constrainView(navigationBarContainer, by: .trailing), + coordinator.constraints.navigationBarCollectionViewBottom ]) } @@ -221,9 +252,11 @@ extension MainViewFactory { let statusBackground = coordinator.statusBackground! let navigationBarContainer = coordinator.navigationBarContainer! - coordinator.constraints.statusBackgroundToNavigationBarContainerBottom = statusBackground.constrainView(navigationBarContainer, by: .bottom) + coordinator.constraints.statusBackgroundToNavigationBarContainerBottom + = statusBackground.constrainView(navigationBarContainer, by: .bottom) - coordinator.constraints.statusBackgroundBottomToSafeAreaTop = statusBackground.constrainView(coordinator.superview.safeAreaLayoutGuide, by: .bottom, to: .top) + coordinator.constraints.statusBackgroundBottomToSafeAreaTop + = statusBackground.constrainView(coordinator.superview.safeAreaLayoutGuide, by: .bottom, to: .top) NSLayoutConstraint.activate([ statusBackground.constrainView(superview, by: .width), @@ -239,8 +272,10 @@ extension MainViewFactory { let navigationBarContainer = coordinator.navigationBarContainer! let statusBackground = coordinator.statusBackground! - coordinator.constraints.notificationContainerTopToNavigationBar = notificationBarContainer.constrainView(navigationBarContainer, by: .top, to: .bottom) - coordinator.constraints.notificationContainerTopToStatusBackground = notificationBarContainer.constrainView(statusBackground, by: .top, to: .bottom) + coordinator.constraints.notificationContainerTopToNavigationBar + = notificationBarContainer.constrainView(navigationBarContainer, by: .top, to: .bottom) + coordinator.constraints.notificationContainerTopToStatusBackground + = notificationBarContainer.constrainView(statusBackground, by: .top, to: .bottom) coordinator.constraints.notificationContainerHeight = notificationBarContainer.constrainAttribute(.height, to: 0) NSLayoutConstraint.activate([ @@ -260,7 +295,8 @@ extension MainViewFactory { coordinator.constraints.contentContainerTop = contentContainer.constrainView(notificationBarContainer, by: .top, to: .bottom) coordinator.constraints.contentContainerBottomToToolbarTop = contentContainer.constrainView(toolbar, by: .bottom, to: .top) - coordinator.constraints.contentContainerBottomToNavigationBarContainerTop = contentContainer.constrainView(navigationBarContainer, by: .bottom, to: .top) + coordinator.constraints.contentContainerBottomToNavigationBarContainerTop + = contentContainer.constrainView(navigationBarContainer, by: .bottom, to: .top) NSLayoutConstraint.activate([ contentContainer.constrainView(superview, by: .leading), @@ -311,127 +347,3 @@ extension MainViewFactory { } } - -class MainViewCoordinator { - - let superview: UIView - - var contentContainer: UIView! - var lastToolbarButton: UIBarButtonItem! - var logo: UIImageView! - var logoContainer: UIView! - var logoText: UIImageView! - var navigationBarContainer: UIView! - var notificationBarContainer: UIView! - var omniBar: OmniBar! - var progress: ProgressView! - var statusBackground: UIView! - var suggestionTrayContainer: UIView! - var tabBarContainer: UIView! - var toolbar: UIToolbar! - var toolbarBackButton: UIBarButtonItem! - var toolbarFireButton: UIBarButtonItem! - var toolbarForwardButton: UIBarButtonItem! - var toolbarTabSwitcherButton: UIBarButtonItem! - - let constraints = Constraints() - - // The default after creating the hiearchy is top - var addressBarPosition: AddressBarPosition = .top - - fileprivate init(superview: UIView) { - self.superview = superview - } - - func decorateWithTheme(_ theme: Theme) { - superview.backgroundColor = theme.mainViewBackgroundColor - logoText.tintColor = theme.ddgTextTintColor - omniBar.decorate(with: theme) - } - - func showToolbarSeparator() { - toolbar.setShadowImage(nil, forToolbarPosition: .any) - } - - func hideToolbarSeparator() { - self.toolbar.setShadowImage(UIImage(), forToolbarPosition: .any) - } - - class Constraints { - - var navigationBarContainerTop: NSLayoutConstraint! - var navigationBarContainerBottom: NSLayoutConstraint! - var toolbarBottom: NSLayoutConstraint! - var contentContainerTop: NSLayoutConstraint! - var tabBarContainerTop: NSLayoutConstraint! - var notificationContainerTopToNavigationBar: NSLayoutConstraint! - var notificationContainerTopToStatusBackground: NSLayoutConstraint! - var notificationContainerHeight: NSLayoutConstraint! - var omniBarBottom: NSLayoutConstraint! - var progressBarTop: NSLayoutConstraint! - var progressBarBottom: NSLayoutConstraint! - var statusBackgroundToNavigationBarContainerBottom: NSLayoutConstraint! - var statusBackgroundBottomToSafeAreaTop: NSLayoutConstraint! - var contentContainerBottomToToolbarTop: NSLayoutConstraint! - var contentContainerBottomToNavigationBarContainerTop: NSLayoutConstraint! - - } - - func moveAddressBarToPosition(_ position: AddressBarPosition) { - guard position != addressBarPosition else { return } - switch position { - case .top: - setAddressBarBottomActive(false) - setAddressBarTopActive(true) - - case .bottom: - setAddressBarTopActive(false) - setAddressBarBottomActive(true) - } - - addressBarPosition = position - } - - func hideNavigationBarWithBottomPosition() { - guard addressBarPosition.isBottom else { - return - } - - // Hiding the container won't suffice as it still defines the contentContainer.bottomY through constraints - navigationBarContainer.isHidden = true - - constraints.contentContainerBottomToNavigationBarContainerTop.isActive = false - constraints.contentContainerBottomToToolbarTop.isActive = true - } - - func showNavigationBarWithBottomPosition() { - guard addressBarPosition.isBottom else { - return - } - - constraints.contentContainerBottomToToolbarTop.isActive = false - constraints.contentContainerBottomToNavigationBarContainerTop.isActive = true - - navigationBarContainer.isHidden = false - } - - func setAddressBarTopActive(_ active: Bool) { - constraints.contentContainerBottomToToolbarTop.isActive = active - constraints.navigationBarContainerTop.isActive = active - constraints.progressBarTop.isActive = active - constraints.notificationContainerTopToNavigationBar.isActive = active - constraints.statusBackgroundToNavigationBarContainerBottom.isActive = active - } - - func setAddressBarBottomActive(_ active: Bool) { - constraints.contentContainerBottomToNavigationBarContainerTop.isActive = active - constraints.progressBarBottom.isActive = active - constraints.navigationBarContainerBottom.isActive = active - constraints.notificationContainerTopToStatusBackground.isActive = active - constraints.statusBackgroundBottomToSafeAreaTop.isActive = active - } - -} - -// swiftlint:enable line_length -// swiftlint:enable file_length diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index c9e0cafaaf..697640fce6 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -38,8 +38,8 @@ import NetworkProtection // swiftlint:disable file_length // swiftlint:disable type_body_length class MainViewController: UIViewController { -// swiftlint:enable type_body_length - + // swiftlint:enable type_body_length + override var preferredStatusBarStyle: UIStatusBarStyle { return ThemeManager.shared.currentTheme.statusBarStyle } @@ -49,15 +49,15 @@ class MainViewController: UIViewController { return isIPad ? [.left, .right] : [] } - + weak var findInPageView: FindInPageView! weak var findInPageHeightLayoutConstraint: NSLayoutConstraint! weak var findInPageBottomLayoutConstraint: NSLayoutConstraint! - + weak var notificationView: NotificationView? - + var chromeManager: BrowserChromeManager! - + var allowContentUnderflow = false { didSet { viewCoordinator.constraints.contentContainerTop.constant = allowContentUnderflow ? contentUnderflow : 0 @@ -67,36 +67,36 @@ class MainViewController: UIViewController { var contentUnderflow: CGFloat { return 3 + (allowContentUnderflow ? -viewCoordinator.navigationBarContainer.frame.size.height : 0) } - + lazy var emailManager: EmailManager = { let emailManager = EmailManager() emailManager.aliasPermissionDelegate = self emailManager.requestDelegate = self return emailManager }() - + var homeController: HomeViewController? var tabsBarController: TabsBarViewController? var suggestionTrayController: SuggestionTrayViewController? - + var tabManager: TabManager! let previewsSource = TabPreviewsSource() let appSettings: AppSettings private var launchTabObserver: LaunchTabNotification.Observer? - + #if APP_TRACKING_PROTECTION private let appTrackingProtectionDatabase: CoreDataDatabase #endif - + let bookmarksDatabase: CoreDataDatabase private weak var bookmarksDatabaseCleaner: BookmarkDatabaseCleaner? private var favoritesViewModel: FavoritesListInteracting let syncService: DDGSyncing let syncDataProviders: SyncDataProviders - + @UserDefaultsWrapper(key: .syncDidShowSyncPausedByFeatureFlagAlert, defaultValue: false) private var syncDidShowSyncPausedByFeatureFlagAlert: Bool - + private var localUpdatesCancellable: AnyCancellable? private var syncUpdatesCancellable: AnyCancellable? private var syncFeatureFlagsCancellable: AnyCancellable? @@ -108,13 +108,13 @@ class MainViewController: UIViewController { #endif private lazy var featureFlagger = AppDependencyProvider.shared.featureFlagger - + lazy var menuBookmarksViewModel: MenuBookmarksInteracting = { let viewModel = MenuBookmarksViewModel(bookmarksDatabase: bookmarksDatabase, syncService: syncService) viewModel.favoritesDisplayMode = appSettings.favoritesDisplayMode return viewModel }() - + weak var tabSwitcherController: TabSwitcherViewController? let tabSwitcherButton = TabSwitcherButton() @@ -129,23 +129,23 @@ class MainViewController: UIViewController { private lazy var fireButtonAnimator: FireButtonAnimator = FireButtonAnimator(appSettings: appSettings) let bookmarksCachingSearch: BookmarksCachingSearch - + lazy var tabSwitcherTransition = TabSwitcherTransitionDelegate() var currentTab: TabViewController? { return tabManager?.current(createIfNeeded: false) } - + var searchBarRect: CGRect { let view = UIApplication.shared.windows.filter({ $0.isKeyWindow }).first?.rootViewController?.view return viewCoordinator.omniBar.searchContainer.convert(viewCoordinator.omniBar.searchContainer.bounds, to: view) } - + var keyModifierFlags: UIKeyModifierFlags? var showKeyboardAfterFireButton: DispatchWorkItem? // Skip SERP flow (focusing on autocomplete logic) and prepare for new navigation when selecting search bar private var skipSERPFlow = true - + private var keyboardHeight: CGFloat = 0.0 var postClear: (() -> Void)? @@ -154,9 +154,9 @@ class MainViewController: UIViewController { required init?(coder: NSCoder) { fatalError("Use init?(code:") } - + var viewCoordinator: MainViewCoordinator! - + #if APP_TRACKING_PROTECTION init( bookmarksDatabase: CoreDataDatabase, @@ -174,9 +174,9 @@ class MainViewController: UIViewController { self.favoritesViewModel = FavoritesListViewModel(bookmarksDatabase: bookmarksDatabase, favoritesDisplayMode: appSettings.favoritesDisplayMode) self.bookmarksCachingSearch = BookmarksCachingSearch(bookmarksStore: CoreDataBookmarksSearchStore(bookmarksStore: bookmarksDatabase)) self.appSettings = appSettings - + super.init(nibName: nil, bundle: nil) - + bindFavoritesDisplayMode() bindSyncService() } @@ -197,35 +197,37 @@ class MainViewController: UIViewController { self.appSettings = appSettings super.init(nibName: nil, bundle: nil) - + bindSyncService() } #endif - + fileprivate var tabCountInfo: TabCountInfo? - + func loadFindInPage() { - + let view = FindInPageView.loadFromXib() self.view.addSubview(view) - + // Avoids coercion swiftlint warnings let superview = self.view! - + let height = view.constrainAttribute(.height, to: view.frame.height) let bottom = superview.constrainView(view, by: .bottom, to: .bottom) - + NSLayoutConstraint.activate([ bottom, superview.constrainView(view, by: .width, to: .width), height, superview.constrainView(view, by: .centerX, to: .centerX) ]) - + findInPageView = view findInPageBottomLayoutConstraint = bottom findInPageHeightLayoutConstraint = height } + + var swipeTabsCoordinator: SwipeTabsCoordinator? override func viewDidLoad() { super.viewDidLoad() @@ -237,6 +239,8 @@ class MainViewController: UIViewController { viewCoordinator.toolbarForwardButton.action = #selector(onForwardPressed) viewCoordinator.toolbarFireButton.action = #selector(onFirePressed) + installSwipeTabs() + loadSuggestionTray() loadTabsBarIfNeeded() loadFindInPage() @@ -267,6 +271,7 @@ class MainViewController: UIViewController { applyTheme(ThemeManager.shared.currentTheme) tabsBarController?.refresh(tabsModel: tabManager.model, scrollToSelected: true) + swipeTabsCoordinator?.refresh(tabsModel: tabManager.model, scrollToSelected: true) _ = AppWidthObserver.shared.willResize(toWidth: view.frame.width) applyWidth() @@ -277,14 +282,19 @@ class MainViewController: UIViewController { tabManager.cleanupTabsFaviconCache() + // Needs to be called here to established correct view hierarchy refreshViewsBasedOnAddressBarPosition(appSettings.currentAddressBarPosition) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + // Needs to be called here because sometimes the frames are not the expected size during didLoad + refreshViewsBasedOnAddressBarPosition(appSettings.currentAddressBarPosition) + startOnboardingFlowIfNotSeenBefore() tabsBarController?.refresh(tabsModel: tabManager.model) + swipeTabsCoordinator?.refresh(tabsModel: tabManager.model) _ = AppWidthObserver.shared.willResize(toWidth: view.frame.width) applyWidth() @@ -298,6 +308,53 @@ class MainViewController: UIViewController { assertionFailure() super.performSegue(withIdentifier: identifier, sender: sender) } + + private func installSwipeTabs() { + guard swipeTabsCoordinator == nil else { return } + + swipeTabsCoordinator = SwipeTabsCoordinator(coordinator: viewCoordinator, + tabPreviewsSource: previewsSource, + appSettings: appSettings) { [weak self] in + + guard $0 != self?.tabManager.model.currentIndex else { return } + + DailyPixel.fire(pixel: .swipeTabsUsedDaily) + Pixel.fire(pixel: .swipeTabsUsed) + self?.select(tabAt: $0) + + } newTab: { [weak self] in + self?.newTab() + } onSwipeStarted: { [weak self] in + self?.hideKeyboard() + self?.updatePreviewForCurrentTab() + } + } + + func updatePreviewForCurrentTab() { + assert(Thread.isMainThread) + + if !viewCoordinator.logoContainer.isHidden, + self.tabManager.current()?.link == nil, + let tab = self.tabManager.model.currentTab { + // Home screen with logo + if let image = viewCoordinator.logoContainer.createImageSnapshot(inBounds: viewCoordinator.contentContainer.frame) { + previewsSource.update(preview: image, forTab: tab) + } + + } else if let currentTab = self.tabManager.current(), currentTab.link != nil { + // Web view + currentTab.preparePreview(completion: { image in + guard let image else { return } + self.previewsSource.update(preview: image, + forTab: currentTab.tabModel) + }) + } else if let tab = self.tabManager.model.currentTab { + // Favorites, etc + if let image = viewCoordinator.contentContainer.createImageSnapshot() { + previewsSource.update(preview: image, forTab: tab) + } + } + } func loadSuggestionTray() { let storyboard = UIStoryboard(name: "SuggestionTray", bundle: nil) @@ -452,10 +509,13 @@ class MainViewController: UIViewController { func refreshViewsBasedOnAddressBarPosition(_ position: AddressBarPosition) { switch position { case .top: + swipeTabsCoordinator?.addressBarPositionChanged(isTop: true) viewCoordinator.omniBar.moveSeparatorToBottom() viewCoordinator.showToolbarSeparator() + viewCoordinator.constraints.navigationBarContainerBottom.isActive = false case .bottom: + swipeTabsCoordinator?.addressBarPositionChanged(isTop: false) viewCoordinator.omniBar.moveSeparatorToTop() // If this is called before the toolbar has shown it will not re-add the separator when moving to the top position DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { @@ -513,7 +573,7 @@ class MainViewController: UIViewController { if self.appSettings.currentAddressBarPosition.isBottom { let navBarOffset = min(0, self.toolbarHeight - intersection.height) - self.viewCoordinator.constraints.omniBarBottom.constant = navBarOffset + self.viewCoordinator.constraints.navigationBarCollectionViewBottom.constant = navBarOffset UIView.animate(withDuration: duration, delay: 0, options: animationCurve) { self.viewCoordinator.navigationBarContainer.superview?.layoutIfNeeded() } @@ -608,7 +668,7 @@ class MainViewController: UIViewController { override var shouldAutorotate: Bool { return true } - + @objc func dismissSuggestionTray() { dismissOmniBar() } @@ -805,6 +865,7 @@ class MainViewController: UIViewController { refreshTabIcon() refreshControls() tabsBarController?.refresh(tabsModel: tabManager.model) + swipeTabsCoordinator?.refresh(tabsModel: tabManager.model) } if clearInProgress { @@ -894,6 +955,7 @@ class MainViewController: UIViewController { refreshControls() } tabsBarController?.refresh(tabsModel: tabManager.model, scrollToSelected: true) + swipeTabsCoordinator?.refresh(tabsModel: tabManager.model, scrollToSelected: true) if DaxDialogs.shared.shouldShowFireButtonPulse { showFireButtonPulse() } @@ -989,7 +1051,9 @@ class MainViewController: UIViewController { self.showMenuHighlighterIfNeeded() - coordinator.animate(alongsideTransition: nil) { _ in + coordinator.animate { _ in + self.swipeTabsCoordinator?.refresh(tabsModel: self.tabManager.model, scrollToSelected: true) + } completion: { _ in ViewHighlighter.updatePositions() } } @@ -1011,6 +1075,7 @@ class MainViewController: UIViewController { } // If tabs have been udpated, do this async to make sure size calcs are current self.tabsBarController?.refresh(tabsModel: self.tabManager.model) + self.swipeTabsCoordinator?.refresh(tabsModel: self.tabManager.model) // Do this on the next UI thread pass so we definitely have the right width self.applyWidthToTrayController() @@ -1052,12 +1117,16 @@ class MainViewController: UIViewController { viewCoordinator.tabBarContainer.isHidden = false viewCoordinator.toolbar.isHidden = true viewCoordinator.omniBar.enterPadState() + + swipeTabsCoordinator?.isEnabled = false } private func applySmallWidth() { viewCoordinator.tabBarContainer.isHidden = true viewCoordinator.toolbar.isHidden = false viewCoordinator.omniBar.enterPhoneState() + + swipeTabsCoordinator?.isEnabled = featureFlagger.isFeatureOn(.swipeTabs) } @discardableResult @@ -1190,8 +1259,9 @@ class MainViewController: UIViewController { tabManager.addHomeTab() } attachHomeScreen() - homeController?.openedAsNewTab(allowingKeyboard: allowingKeyboard) tabsBarController?.refresh(tabsModel: tabManager.model) + swipeTabsCoordinator?.refresh(tabsModel: tabManager.model) + homeController?.openedAsNewTab(allowingKeyboard: allowingKeyboard) } func animateLogoAppearance() { @@ -1792,6 +1862,7 @@ extension MainViewController: TabDelegate { } tabManager?.save() tabsBarController?.refresh(tabsModel: tabManager.model) + swipeTabsCoordinator?.refresh(tabsModel: tabManager.model) } func tab(_ tab: TabViewController, didUpdatePreview preview: UIImage) { @@ -2091,6 +2162,7 @@ extension MainViewController: AutoClearWorker { showBars() attachHomeScreen() tabsBarController?.refresh(tabsModel: tabManager.model) + swipeTabsCoordinator?.refresh(tabsModel: tabManager.model) Favicons.shared.clearCache(.tabs) } diff --git a/DuckDuckGo/MainViewCoordinator.swift b/DuckDuckGo/MainViewCoordinator.swift new file mode 100644 index 0000000000..9906ef7767 --- /dev/null +++ b/DuckDuckGo/MainViewCoordinator.swift @@ -0,0 +1,147 @@ +// +// MainViewCoordinator.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +class MainViewCoordinator { + + let superview: UIView + + var contentContainer: UIView! + var lastToolbarButton: UIBarButtonItem! + var logo: UIImageView! + var logoContainer: UIView! + var logoText: UIImageView! + var navigationBarContainer: UIView! + var navigationBarCollectionView: MainViewFactory.NavigationBarCollectionView! + var notificationBarContainer: UIView! + var omniBar: OmniBar! + var progress: ProgressView! + var statusBackground: UIView! + var suggestionTrayContainer: UIView! + var tabBarContainer: UIView! + var toolbar: UIToolbar! + var toolbarBackButton: UIBarButtonItem! + var toolbarFireButton: UIBarButtonItem! + var toolbarForwardButton: UIBarButtonItem! + var toolbarTabSwitcherButton: UIBarButtonItem! + + let constraints = Constraints() + + // The default after creating the hiearchy is top + var addressBarPosition: AddressBarPosition = .top + + /// STOP - why are you instanciating this? + init(superview: UIView) { + self.superview = superview + } + + func showToolbarSeparator() { + toolbar.setShadowImage(nil, forToolbarPosition: .any) + } + + func hideToolbarSeparator() { + self.toolbar.setShadowImage(UIImage(), forToolbarPosition: .any) + } + + class Constraints { + + var navigationBarContainerTop: NSLayoutConstraint! + var navigationBarContainerBottom: NSLayoutConstraint! + var navigationBarCollectionViewBottom: NSLayoutConstraint! + var toolbarBottom: NSLayoutConstraint! + var contentContainerTop: NSLayoutConstraint! + var tabBarContainerTop: NSLayoutConstraint! + var notificationContainerTopToNavigationBar: NSLayoutConstraint! + var notificationContainerTopToStatusBackground: NSLayoutConstraint! + var notificationContainerHeight: NSLayoutConstraint! + var progressBarTop: NSLayoutConstraint! + var progressBarBottom: NSLayoutConstraint! + var statusBackgroundToNavigationBarContainerBottom: NSLayoutConstraint! + var statusBackgroundBottomToSafeAreaTop: NSLayoutConstraint! + var contentContainerBottomToToolbarTop: NSLayoutConstraint! + var contentContainerBottomToNavigationBarContainerTop: NSLayoutConstraint! + + } + + func moveAddressBarToPosition(_ position: AddressBarPosition) { + guard position != addressBarPosition else { return } + switch position { + case .top: + setAddressBarBottomActive(false) + setAddressBarTopActive(true) + + case .bottom: + setAddressBarTopActive(false) + setAddressBarBottomActive(true) + } + + addressBarPosition = position + } + + func hideNavigationBarWithBottomPosition() { + guard addressBarPosition.isBottom else { + return + } + + // Hiding the container won't suffice as it still defines the contentContainer.bottomY through constraints + navigationBarContainer.isHidden = true + + constraints.contentContainerBottomToNavigationBarContainerTop.isActive = false + constraints.contentContainerBottomToToolbarTop.isActive = true + } + + func showNavigationBarWithBottomPosition() { + guard addressBarPosition.isBottom else { + return + } + + constraints.contentContainerBottomToToolbarTop.isActive = false + constraints.contentContainerBottomToNavigationBarContainerTop.isActive = true + + navigationBarContainer.isHidden = false + } + + func setAddressBarTopActive(_ active: Bool) { + constraints.contentContainerBottomToToolbarTop.isActive = active + constraints.navigationBarContainerTop.isActive = active + constraints.progressBarTop.isActive = active + constraints.notificationContainerTopToNavigationBar.isActive = active + constraints.statusBackgroundToNavigationBarContainerBottom.isActive = active + } + + func setAddressBarBottomActive(_ active: Bool) { + constraints.contentContainerBottomToNavigationBarContainerTop.isActive = active + constraints.progressBarBottom.isActive = active + constraints.navigationBarContainerBottom.isActive = active + constraints.notificationContainerTopToStatusBackground.isActive = active + constraints.statusBackgroundBottomToSafeAreaTop.isActive = active + } + +} + +extension MainViewCoordinator: Themable { + + func decorate(with theme: Theme) { + superview.backgroundColor = theme.mainViewBackgroundColor + logoText.tintColor = theme.ddgTextTintColor + omniBar.decorate(with: theme) + } + +} diff --git a/DuckDuckGo/NavigationSearchHomeViewSectionRenderer.swift b/DuckDuckGo/NavigationSearchHomeViewSectionRenderer.swift index 5830de7157..34f23fb686 100644 --- a/DuckDuckGo/NavigationSearchHomeViewSectionRenderer.swift +++ b/DuckDuckGo/NavigationSearchHomeViewSectionRenderer.swift @@ -46,8 +46,11 @@ class NavigationSearchHomeViewSectionRenderer: HomeViewSectionRenderer { } func openedAsNewTab(allowingKeyboard: Bool) { - if allowingKeyboard && KeyboardSettings().onNewTab { - launchNewSearch() + guard allowingKeyboard && KeyboardSettings().onNewTab else { return } + // The omnibar is inside a collection view so this needs to chance to do its thing + // which might also be async. Not great. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.launchNewSearch() } } diff --git a/DuckDuckGo/OmniBar.swift b/DuckDuckGo/OmniBar.swift index 4a3bc54fd0..282bd81f81 100644 --- a/DuckDuckGo/OmniBar.swift +++ b/DuckDuckGo/OmniBar.swift @@ -66,7 +66,6 @@ class OmniBar: UIView { weak var omniDelegate: OmniBarDelegate? fileprivate var state: OmniBarState = SmallOmniBarState.HomeNonEditingState() - private var safeAreaInsetsObservation: NSKeyValueObservation? private var privacyIconAndTrackersAnimator = PrivacyIconAndTrackersAnimator() private var notificationAnimator = OmniBarNotificationAnimator() @@ -100,7 +99,6 @@ class OmniBar: UIView { configureEditingMenu() refreshState(state) enableInteractionsWithPointer() - observeSafeAreaInsets() privacyInfoContainer.isHidden = true } @@ -140,13 +138,7 @@ class OmniBar: UIView { name: .speechRecognizerDidChangeAvailability, object: nil) } - - private func observeSafeAreaInsets() { - safeAreaInsetsObservation = self.observe(\.safeAreaInsets, options: .new) { [weak self] (_, _) in - self?.updateOmniBarPadding() - } - } - + private func enableInteractionsWithPointer() { backButton.isPointerInteractionEnabled = true forwardButton.isPointerInteractionEnabled = true @@ -346,8 +338,6 @@ class OmniBar: UIView { if state.showVoiceSearch && state.showClear { searchStackContainer.setCustomSpacing(13, after: voiceSearchButton) } - - updateOmniBarPadding() UIView.animate(withDuration: 0.0) { self.layoutIfNeeded() @@ -355,9 +345,9 @@ class OmniBar: UIView { } - private func updateOmniBarPadding() { - omniBarLeadingConstraint.constant = (state.hasLargeWidth ? 24 : 8) + safeAreaInsets.left - omniBarTrailingConstraint.constant = (state.hasLargeWidth ? 24 : 14) + safeAreaInsets.right + func updateOmniBarPadding(left: CGFloat, right: CGFloat) { + omniBarLeadingConstraint.constant = (state.hasLargeWidth ? 24 : 8) + left + omniBarTrailingConstraint.constant = (state.hasLargeWidth ? 24 : 14) + right } /* diff --git a/DuckDuckGo/SwipeTabsCoordinator.swift b/DuckDuckGo/SwipeTabsCoordinator.swift new file mode 100644 index 0000000000..180d9a17a0 --- /dev/null +++ b/DuckDuckGo/SwipeTabsCoordinator.swift @@ -0,0 +1,359 @@ +// +// SwipeTabsCoordinator.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +class SwipeTabsCoordinator: NSObject { + + static let tabGap: CGFloat = 10 + + // Set by refresh function + weak var tabsModel: TabsModel! + + weak var coordinator: MainViewCoordinator! + weak var tabPreviewsSource: TabPreviewsSource! + weak var appSettings: AppSettings! + + let selectTab: (Int) -> Void + let newTab: () -> Void + let onSwipeStarted: () -> Void + + let feedbackGenerator: UISelectionFeedbackGenerator = { + let generator = UISelectionFeedbackGenerator() + generator.prepare() + return generator + }() + + var isEnabled = false { + didSet { + collectionView.reloadData() + } + } + + var collectionView: MainViewFactory.NavigationBarCollectionView { + coordinator.navigationBarCollectionView + } + + init(coordinator: MainViewCoordinator, + tabPreviewsSource: TabPreviewsSource, + appSettings: AppSettings, + selectTab: @escaping (Int) -> Void, + newTab: @escaping () -> Void, + onSwipeStarted: @escaping () -> Void) { + + self.coordinator = coordinator + self.tabPreviewsSource = tabPreviewsSource + self.appSettings = appSettings + + self.selectTab = selectTab + self.newTab = newTab + self.onSwipeStarted = onSwipeStarted + + super.init() + + collectionView.register(OmniBarCell.self, forCellWithReuseIdentifier: "omnibar") + collectionView.isPagingEnabled = true + collectionView.delegate = self + collectionView.dataSource = self + collectionView.decelerationRate = .fast + collectionView.backgroundColor = .clear + + updateLayout() + } + + enum State { + + case idle + case starting(CGPoint) + case swiping(CGPoint, FloatingPointSign) + + } + + var state: State = .idle + + weak var preview: UIView? + weak var currentView: UIView? + + private func updateLayout() { + let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout + layout?.scrollDirection = .horizontal + layout?.itemSize = CGSize(width: coordinator.superview.frame.size.width, height: coordinator.omniBar.frame.height) + layout?.minimumLineSpacing = 0 + layout?.minimumInteritemSpacing = 0 + layout?.scrollDirection = .horizontal + } + + private func scrollToCurrent() { + guard isEnabled else { return } + + let targetOffset = collectionView.frame.width * CGFloat(tabsModel.currentIndex) + + guard targetOffset != collectionView.contentOffset.x else { + return + } + + let indexPath = IndexPath(row: self.tabsModel.currentIndex, section: 0) + self.collectionView.scrollToItem(at: indexPath, + at: .centeredHorizontally, + animated: false) + } +} + +// MARK: UICollectionViewDelegate +extension SwipeTabsCoordinator: UICollectionViewDelegate { + + func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + DispatchQueue.main.async { + self.scrollToCurrent() + } + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + + switch state { + case .idle: break + + case .starting(let startPosition): + let offset = startPosition.x - scrollView.contentOffset.x + prepareCurrentView() + preparePreview(offset) + state = .swiping(startPosition, offset.sign) + onSwipeStarted() + + case .swiping(let startPosition, let sign): + let offset = startPosition.x - scrollView.contentOffset.x + if offset.sign == sign { + let modifier = sign == .plus ? -1.0 : 1.0 + swipePreviewProportionally(offset: offset, modifier: modifier) + swipeCurrentViewProportionally(offset: offset) + currentView?.transform.tx = offset + } else { + cleanUpViews() + state = .starting(startPosition) + } + } + } + + private func swipeCurrentViewProportionally(offset: CGFloat) { + currentView?.transform.tx = offset + } + + private func swipePreviewProportionally(offset: CGFloat, modifier: CGFloat) { + let width = coordinator.contentContainer.frame.width + let percent = offset / width + let swipeWidth = width + Self.tabGap + let x = (swipeWidth * percent) + (Self.tabGap * modifier) + preview?.transform.tx = x + } + + private func prepareCurrentView() { + + if !coordinator.logoContainer.isHidden { + currentView = coordinator.logoContainer + } else { + currentView = coordinator.contentContainer.subviews.last + } + } + + private func preparePreview(_ offset: CGFloat) { + let modifier = (offset > 0 ? -1 : 1) + let nextIndex = tabsModel.currentIndex + modifier + + guard tabsModel.tabs.indices.contains(nextIndex) || tabsModel.tabs.last?.link != nil else { + return + } + + let targetFrame = CGRect(origin: .zero, size: coordinator.contentContainer.frame.size) + + let tab = tabsModel.safeGetTabAt(nextIndex) + if let tab, let image = tabPreviewsSource.preview(for: tab) { + createPreviewFromImage(image) + } else if tab?.link == nil { + createPreviewFromLogoContainerWithSize(targetFrame.size) + } + + preview?.frame = targetFrame + preview?.frame.origin.x = coordinator.contentContainer.frame.width * CGFloat(modifier) + } + + private func createPreviewFromImage(_ image: UIImage) { + let imageView = UIImageView(image: image) + imageView.contentMode = .scaleAspectFill + coordinator.contentContainer.addSubview(imageView) + preview = imageView + } + + private func createPreviewFromLogoContainerWithSize(_ size: CGSize) { + let origin = coordinator.contentContainer.convert(CGPoint.zero, to: coordinator.logoContainer) + let snapshotFrame = CGRect(origin: origin, size: size) + let isHidden = coordinator.logoContainer.isHidden + coordinator.logoContainer.isHidden = false + if let snapshotView = coordinator.logoContainer.resizableSnapshotView(from: snapshotFrame, + afterScreenUpdates: true, + withCapInsets: .zero) { + coordinator.contentContainer.addSubview(snapshotView) + preview = snapshotView + } + coordinator.logoContainer.isHidden = isHidden + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + switch state { + case .idle: + state = .starting(scrollView.contentOffset) + + default: break + } + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + defer { + cleanUpViews() + state = .idle + } + + let point = CGPoint(x: coordinator.navigationBarCollectionView.bounds.midX, + y: coordinator.navigationBarCollectionView.bounds.midY) + + guard let index = coordinator.navigationBarCollectionView.indexPathForItem(at: point)?.row else { + assertionFailure("invalid index") + return + } + feedbackGenerator.selectionChanged() + if index >= tabsModel.count { + newTab() + } else { + selectTab(index) + } + } + + private func cleanUpViews() { + currentView?.transform = .identity + currentView = nil + preview?.removeFromSuperview() + } + +} + +// MARK: Public Interface +extension SwipeTabsCoordinator { + + func refresh(tabsModel: TabsModel, scrollToSelected: Bool = false) { + self.tabsModel = tabsModel + coordinator.navigationBarCollectionView.reloadData() + + updateLayout() + + if scrollToSelected { + scrollToCurrent() + } + } + + func addressBarPositionChanged(isTop: Bool) { + if isTop { + collectionView.horizontalScrollIndicatorInsets.bottom = -1.5 + collectionView.hitTestInsets.top = -12 + collectionView.hitTestInsets.bottom = 0 + } else { + collectionView.horizontalScrollIndicatorInsets.bottom = collectionView.frame.height - 7.5 + collectionView.hitTestInsets.top = 0 + collectionView.hitTestInsets.bottom = -12 + } + } + +} + +// MARK: UICollectionViewDataSource +extension SwipeTabsCoordinator: UICollectionViewDataSource { + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + guard isEnabled else { return 1 } + let extras = tabsModel.tabs.last?.link != nil ? 1 : 0 // last tab is not a home page, so let's add one + let count = tabsModel.count + extras + return count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "omnibar", for: indexPath) as? OmniBarCell else { + fatalError("Not \(OmniBarCell.self)") + } + + if !isEnabled || tabsModel.currentIndex == indexPath.row { + cell.omniBar = coordinator.omniBar + } else { + cell.omniBar = OmniBar.loadFromXib() + cell.omniBar?.translatesAutoresizingMaskIntoConstraints = false + cell.updateConstraints() + cell.omniBar?.decorate(with: ThemeManager.shared.currentTheme) + + cell.omniBar?.showSeparator() + if self.appSettings.currentAddressBarPosition.isBottom { + cell.omniBar?.moveSeparatorToTop() + } else { + cell.omniBar?.moveSeparatorToBottom() + } + + if let url = tabsModel.safeGetTabAt(indexPath.row)?.link?.url { + cell.omniBar?.startBrowsing() + cell.omniBar?.refreshText(forUrl: url) + } + + } + + return cell + } + +} + +class OmniBarCell: UICollectionViewCell { + + weak var omniBar: OmniBar? { + didSet { + subviews.forEach { $0.removeFromSuperview() } + if let omniBar { + addSubview(omniBar) + + NSLayoutConstraint.activate([ + constrainView(omniBar, by: .leadingMargin), + constrainView(omniBar, by: .trailingMargin), + constrainView(omniBar, by: .top), + constrainView(omniBar, by: .bottom), + ]) + + } + } + } + + override func updateConstraints() { + super.updateConstraints() + let left = superview?.safeAreaInsets.left ?? 0 + let right = superview?.safeAreaInsets.right ?? 0 + omniBar?.updateOmniBarPadding(left: left, right: right) + } + +} + +extension TabsModel { + + func safeGetTabAt(_ index: Int) -> Tab? { + guard tabs.indices.contains(index) else { return nil } + return tabs[index] + } + +} diff --git a/DuckDuckGo/WebViewTransition.swift b/DuckDuckGo/WebViewTransition.swift index af7ffbc8fe..0b25b7ba38 100644 --- a/DuckDuckGo/WebViewTransition.swift +++ b/DuckDuckGo/WebViewTransition.swift @@ -165,7 +165,6 @@ class ToWebViewTransition: WebViewTransition { scrollIfOutsideViewport(collectionView: tabSwitcherViewController.collectionView, rowIndex: rowIndex, attributes: layoutAttr) UIView.animate(withDuration: TabSwitcherTransition.Constants.duration, animations: { - let frame = CGRect(x: 0, y: webView.scrollView.contentInset.top, width: webViewFrame.width, height: webViewFrame.height) self.imageContainer.frame = mainViewController.viewCoordinator.contentContainer.frame self.imageContainer.layer.cornerRadius = 0