diff --git a/.gitignore b/.gitignore index 15b5d1d..2b8dc88 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,5 @@ fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png fastlane/test_output +/.DS_Store +/Example/.DS_Store diff --git a/Example/MagazineLayoutExample.xcodeproj/project.pbxproj b/Example/MagazineLayoutExample.xcodeproj/project.pbxproj index 23fb5aa..3853f04 100644 --- a/Example/MagazineLayoutExample.xcodeproj/project.pbxproj +++ b/Example/MagazineLayoutExample.xcodeproj/project.pbxproj @@ -3,42 +3,14 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ - 8F78E959225BD81000CAE309 /* Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F78E958225BD81000CAE309 /* Background.swift */; }; - DB68BA8324C7754400365AD4 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23F60021AF519700AA78D4 /* AppDelegate.swift */; }; - DB68BA8424C7754700365AD4 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23F60221AF519700AA78D4 /* ViewController.swift */; }; - DB68BA8524C7754B00365AD4 /* CreationPanelViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD138CE021B23D64006BFABC /* CreationPanelViewController.swift */; }; - DB68BA8624C7754E00365AD4 /* DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23F62921AF599000AA78D4 /* DataSource.swift */; }; - DB68BA8724C7755000365AD4 /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD138CD321B201D0006BFABC /* Data.swift */; }; - DB68BA8824C7755300365AD4 /* DataSourceCountsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD138CDE21B2310A006BFABC /* DataSourceCountsProvider.swift */; }; - DB68BA8924C7755500365AD4 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD89ED1821BFAD7D00607E70 /* Colors.swift */; }; - DB68BA8A24C7755800365AD4 /* Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD138CD721B226A7006BFABC /* Header.swift */; }; - DB68BA8B24C7755B00365AD4 /* Footer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCAC64292208870300973F4C /* Footer.swift */; }; - DB68BA8C24C7755E00365AD4 /* Cell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD138CD521B20875006BFABC /* Cell.swift */; }; - DB68BA8D24C7756100365AD4 /* Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F78E958225BD81000CAE309 /* Background.swift */; }; - DB68BA8E24C7756300365AD4 /* ItemCreationPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD138CDC21B22BBE006BFABC /* ItemCreationPanelView.swift */; }; - DB68BA8F24C7756600365AD4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FD23F60721AF519900AA78D4 /* Assets.xcassets */; }; DB68BA9224C775B200365AD4 /* MagazineLayout.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68BA9124C775B200365AD4 /* MagazineLayout.framework */; }; DB68BA9324C775B200365AD4 /* MagazineLayout.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DB68BA9124C775B200365AD4 /* MagazineLayout.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - DB68BA9624C7775F00365AD4 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DB68BA9524C7775F00365AD4 /* LaunchScreen.storyboard */; }; - FCAC642A2208870300973F4C /* Footer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCAC64292208870300973F4C /* Footer.swift */; }; - FD138CD421B201D0006BFABC /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD138CD321B201D0006BFABC /* Data.swift */; }; - FD138CD621B20875006BFABC /* Cell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD138CD521B20875006BFABC /* Cell.swift */; }; - FD138CD821B226A7006BFABC /* Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD138CD721B226A7006BFABC /* Header.swift */; }; - FD138CDD21B22BBE006BFABC /* ItemCreationPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD138CDC21B22BBE006BFABC /* ItemCreationPanelView.swift */; }; - FD138CDF21B2310A006BFABC /* DataSourceCountsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD138CDE21B2310A006BFABC /* DataSourceCountsProvider.swift */; }; - FD138CE121B23D64006BFABC /* CreationPanelViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD138CE021B23D64006BFABC /* CreationPanelViewController.swift */; }; - FD23F60121AF519700AA78D4 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23F60021AF519700AA78D4 /* AppDelegate.swift */; }; - FD23F60321AF519700AA78D4 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23F60221AF519700AA78D4 /* ViewController.swift */; }; - FD23F60821AF519900AA78D4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FD23F60721AF519900AA78D4 /* Assets.xcassets */; }; - FD23F60B21AF519900AA78D4 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FD23F60921AF519900AA78D4 /* LaunchScreen.storyboard */; }; - FD23F62A21AF599100AA78D4 /* DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23F62921AF599000AA78D4 /* DataSource.swift */; }; FD69EAB021BA2B17001E0650 /* MagazineLayout.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD23F62721AF578300AA78D4 /* MagazineLayout.framework */; }; FD69EAB121BA2B17001E0650 /* MagazineLayout.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = FD23F62721AF578300AA78D4 /* MagazineLayout.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - FD89ED1921BFAD7D00607E70 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD89ED1821BFAD7D00607E70 /* Colors.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -67,29 +39,34 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 8F78E958225BD81000CAE309 /* Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Background.swift; sourceTree = ""; }; DB68BA7124C7751100365AD4 /* MagazineLayoutExampleAppleTV.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MagazineLayoutExampleAppleTV.app; sourceTree = BUILT_PRODUCTS_DIR; }; - DB68BA7F24C7751400365AD4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DB68BA9124C775B200365AD4 /* MagazineLayout.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MagazineLayout.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - DB68BA9524C7775F00365AD4 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; - FCAC64292208870300973F4C /* Footer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Footer.swift; sourceTree = ""; }; - FD138CD321B201D0006BFABC /* Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = ""; }; - FD138CD521B20875006BFABC /* Cell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cell.swift; sourceTree = ""; }; - FD138CD721B226A7006BFABC /* Header.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Header.swift; sourceTree = ""; }; - FD138CDC21B22BBE006BFABC /* ItemCreationPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCreationPanelView.swift; sourceTree = ""; }; - FD138CDE21B2310A006BFABC /* DataSourceCountsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSourceCountsProvider.swift; sourceTree = ""; }; - FD138CE021B23D64006BFABC /* CreationPanelViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreationPanelViewController.swift; sourceTree = ""; }; FD23F5FD21AF519700AA78D4 /* MagazineLayoutExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MagazineLayoutExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; - FD23F60021AF519700AA78D4 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - FD23F60221AF519700AA78D4 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; - FD23F60721AF519900AA78D4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - FD23F60A21AF519900AA78D4 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - FD23F60C21AF519900AA78D4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FD23F62721AF578300AA78D4 /* MagazineLayout.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MagazineLayout.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - FD23F62921AF599000AA78D4 /* DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSource.swift; sourceTree = ""; }; - FD89ED1821BFAD7D00607E70 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 93F37BB82EB979A700E673A7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = FD23F5FC21AF519700AA78D4 /* MagazineLayoutExample */; + }; + 93F37BB92EB979AE00E673A7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = DB68BA7024C7751100365AD4 /* MagazineLayoutExampleAppleTV */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 93ACC4452EB9776B008C1105 /* MagazineLayoutExample */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (93F37BB82EB979A700E673A7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = MagazineLayoutExample; sourceTree = ""; }; + 93ACC4502EB97781008C1105 /* MagazineLayoutExampleAppleTV */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (93F37BB92EB979AE00E673A7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = MagazineLayoutExampleAppleTV; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ DB68BA6E24C7751100365AD4 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -110,42 +87,11 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - DB68BA7224C7751100365AD4 /* MagazineLayoutExampleAppleTV */ = { - isa = PBXGroup; - children = ( - DB68BA7F24C7751400365AD4 /* Info.plist */, - DB68BA9524C7775F00365AD4 /* LaunchScreen.storyboard */, - ); - path = MagazineLayoutExampleAppleTV; - sourceTree = ""; - }; - FD138CD921B22AE9006BFABC /* Views */ = { - isa = PBXGroup; - children = ( - FD138CD721B226A7006BFABC /* Header.swift */, - FCAC64292208870300973F4C /* Footer.swift */, - FD138CD521B20875006BFABC /* Cell.swift */, - 8F78E958225BD81000CAE309 /* Background.swift */, - FD138CDC21B22BBE006BFABC /* ItemCreationPanelView.swift */, - ); - path = Views; - sourceTree = ""; - }; - FD138CDB21B22AF7006BFABC /* Data */ = { - isa = PBXGroup; - children = ( - FD23F62921AF599000AA78D4 /* DataSource.swift */, - FD138CD321B201D0006BFABC /* Data.swift */, - FD138CDE21B2310A006BFABC /* DataSourceCountsProvider.swift */, - ); - path = Data; - sourceTree = ""; - }; FD23F5F421AF519700AA78D4 = { isa = PBXGroup; children = ( - FD23F5FF21AF519700AA78D4 /* MagazineLayoutExample */, - DB68BA7224C7751100365AD4 /* MagazineLayoutExampleAppleTV */, + 93ACC4452EB9776B008C1105 /* MagazineLayoutExample */, + 93ACC4502EB97781008C1105 /* MagazineLayoutExampleAppleTV */, FD23F5FE21AF519700AA78D4 /* Products */, FD23F62421AF548C00AA78D4 /* Frameworks */, ); @@ -160,22 +106,6 @@ name = Products; sourceTree = ""; }; - FD23F5FF21AF519700AA78D4 /* MagazineLayoutExample */ = { - isa = PBXGroup; - children = ( - FD23F60C21AF519900AA78D4 /* Info.plist */, - FD23F60021AF519700AA78D4 /* AppDelegate.swift */, - FD23F60221AF519700AA78D4 /* ViewController.swift */, - FD138CE021B23D64006BFABC /* CreationPanelViewController.swift */, - FD138CDB21B22AF7006BFABC /* Data */, - FD138CD921B22AE9006BFABC /* Views */, - FD89ED1821BFAD7D00607E70 /* Colors.swift */, - FD23F60721AF519900AA78D4 /* Assets.xcassets */, - FD23F60921AF519900AA78D4 /* LaunchScreen.storyboard */, - ); - path = MagazineLayoutExample; - sourceTree = ""; - }; FD23F62421AF548C00AA78D4 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -201,6 +131,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 93ACC4502EB97781008C1105 /* MagazineLayoutExampleAppleTV */, + ); name = MagazineLayoutExampleAppleTV; productName = MagazineLayoutExampleAppleTV; productReference = DB68BA7124C7751100365AD4 /* MagazineLayoutExampleAppleTV.app */; @@ -219,6 +152,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 93ACC4452EB9776B008C1105 /* MagazineLayoutExample */, + ); name = MagazineLayoutExample; productName = MagazineLayoutExample; productReference = FD23F5FD21AF519700AA78D4 /* MagazineLayoutExample.app */; @@ -267,8 +203,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - DB68BA9624C7775F00365AD4 /* LaunchScreen.storyboard in Resources */, - DB68BA8F24C7756600365AD4 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -276,8 +210,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - FD23F60B21AF519900AA78D4 /* LaunchScreen.storyboard in Resources */, - FD23F60821AF519900AA78D4 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -288,18 +220,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - DB68BA8D24C7756100365AD4 /* Background.swift in Sources */, - DB68BA8B24C7755B00365AD4 /* Footer.swift in Sources */, - DB68BA8A24C7755800365AD4 /* Header.swift in Sources */, - DB68BA8424C7754700365AD4 /* ViewController.swift in Sources */, - DB68BA8724C7755000365AD4 /* Data.swift in Sources */, - DB68BA8E24C7756300365AD4 /* ItemCreationPanelView.swift in Sources */, - DB68BA8524C7754B00365AD4 /* CreationPanelViewController.swift in Sources */, - DB68BA8924C7755500365AD4 /* Colors.swift in Sources */, - DB68BA8624C7754E00365AD4 /* DataSource.swift in Sources */, - DB68BA8C24C7755E00365AD4 /* Cell.swift in Sources */, - DB68BA8324C7754400365AD4 /* AppDelegate.swift in Sources */, - DB68BA8824C7755300365AD4 /* DataSourceCountsProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -307,34 +227,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 8F78E959225BD81000CAE309 /* Background.swift in Sources */, - FD138CDF21B2310A006BFABC /* DataSourceCountsProvider.swift in Sources */, - FD89ED1921BFAD7D00607E70 /* Colors.swift in Sources */, - FD138CE121B23D64006BFABC /* CreationPanelViewController.swift in Sources */, - FD23F62A21AF599100AA78D4 /* DataSource.swift in Sources */, - FD138CDD21B22BBE006BFABC /* ItemCreationPanelView.swift in Sources */, - FD138CD421B201D0006BFABC /* Data.swift in Sources */, - FD138CD621B20875006BFABC /* Cell.swift in Sources */, - FD23F60321AF519700AA78D4 /* ViewController.swift in Sources */, - FD138CD821B226A7006BFABC /* Header.swift in Sources */, - FCAC642A2208870300973F4C /* Footer.swift in Sources */, - FD23F60121AF519700AA78D4 /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXVariantGroup section */ - FD23F60921AF519900AA78D4 /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - FD23F60A21AF519900AA78D4 /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - /* Begin XCBuildConfiguration section */ DB68BA8024C7751400365AD4 /* Debug */ = { isa = XCBuildConfiguration; @@ -342,7 +239,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = MagazineLayoutExampleAppleTV/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -351,7 +248,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; SWIFT_VERSION = 5.0; - TVOS_DEPLOYMENT_TARGET = 15.0; + TVOS_DEPLOYMENT_TARGET = 16.0; }; name = Debug; }; @@ -361,7 +258,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = MagazineLayoutExampleAppleTV/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -370,7 +267,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; SWIFT_VERSION = 5.0; - TVOS_DEPLOYMENT_TARGET = 15.0; + TVOS_DEPLOYMENT_TARGET = 16.0; }; name = Release; }; @@ -502,7 +399,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 5Q5SGQT2R4; INFOPLIST_FILE = MagazineLayoutExample/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -524,7 +421,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 5Q5SGQT2R4; INFOPLIST_FILE = MagazineLayoutExample/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Example/MagazineLayoutExample/AppDelegate.swift b/Example/MagazineLayoutExample/AppDelegate.swift index a35c3a6..1458ba8 100644 --- a/Example/MagazineLayoutExample/AppDelegate.swift +++ b/Example/MagazineLayoutExample/AppDelegate.swift @@ -20,23 +20,27 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: Internal - var window: UIWindow? - func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - window = UIWindow(frame: UIScreen.main.bounds) - - let navigationController = UINavigationController(rootViewController: ViewController()) - - window?.rootViewController = navigationController - window?.makeKeyAndVisible() - return true } + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) + -> UISceneConfiguration + { + let sceneConfiguration = UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role) + sceneConfiguration.delegateClass = SceneDelegate.self + return sceneConfiguration + } + func applicationWillResignActive(_ application: UIApplication) { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. diff --git a/Example/MagazineLayoutExample/Colors.swift b/Example/MagazineLayoutExample/Colors.swift deleted file mode 100644 index 17cb634..0000000 --- a/Example/MagazineLayoutExample/Colors.swift +++ /dev/null @@ -1,23 +0,0 @@ -// Created by bryankeller on 12/11/18. -// Copyright © 2018 Airbnb, Inc. - -// 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 - -enum Colors { - static let red = UIColor(red: 0.92, green: 0.26, blue: 0.24, alpha: 1) - static let orange = UIColor(red: 0.95, green: 0.47, blue: 0.25, alpha: 1) - static let green = UIColor(red: 0.49, green: 0.75, blue: 0.29, alpha: 1) - static let blue = UIColor(red: 0.10, green: 0.58, blue: 0.80, alpha: 1) -} diff --git a/Example/MagazineLayoutExample/CreationPanelViewController.swift b/Example/MagazineLayoutExample/CreationPanelViewController.swift deleted file mode 100644 index 1c013d5..0000000 --- a/Example/MagazineLayoutExample/CreationPanelViewController.swift +++ /dev/null @@ -1,93 +0,0 @@ -// Created by bryankeller on 11/30/18. -// Copyright © 2018 Airbnb, Inc. - -// 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 - -#if os(iOS) -final class CreationPanelViewController: UIViewController { - - // MARK: Lifecycle - - init( - dataSourceCountsProvider: DataSourceCountsProvider, - initialState: ItemCreationPanelViewState?) - { - itemCreationPanelView = ItemCreationPanelView( - dataSourceCountsProvider: dataSourceCountsProvider) - - if let initialState = initialState { - itemCreationPanelView.state = initialState - } - - super.init(nibName: nil, bundle: nil) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Internal - - /// A closure that is retained to handle the done button being tapped - var doneButtonTapHandler: ((ItemCreationPanelViewState) -> Void)? - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .white - - navigationItem.title = "Create Item" - navigationItem.rightBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .done, - target: self, - action: #selector(doneButtonTapped)) - - navigationItem.leftBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .cancel, - target: self, - action: #selector(cancelTapped)) - - view.addSubview(itemCreationPanelView) - - itemCreationPanelView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - itemCreationPanelView.leadingAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.leadingAnchor), - itemCreationPanelView.trailingAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.trailingAnchor), - itemCreationPanelView.topAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.topAnchor, - constant: 24), - itemCreationPanelView.bottomAnchor.constraint( - lessThanOrEqualTo: view.safeAreaLayoutGuide.bottomAnchor), - ]) - } - - // MARK: Private - - private let itemCreationPanelView: ItemCreationPanelView - - @objc - private func cancelTapped() { - dismiss(animated: true, completion: nil) - } - - @objc - private func doneButtonTapped() { - doneButtonTapHandler?(itemCreationPanelView.state) - } - -} -#endif diff --git a/Example/MagazineLayoutExample/Data/Data.swift b/Example/MagazineLayoutExample/Data/Data.swift deleted file mode 100644 index 03b65b4..0000000 --- a/Example/MagazineLayoutExample/Data/Data.swift +++ /dev/null @@ -1,64 +0,0 @@ -// Created by bryankeller on 11/30/18. -// Copyright © 2018 Airbnb, Inc. - -// 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 MagazineLayout -import UIKit - -// MARK: - SectionInfo - -struct SectionInfo { - - var headerInfo: HeaderInfo - var itemInfos: [ItemInfo] - var footerInfo: FooterInfo - var backgroundInfo: BackgroundInfo - -} - -// MARK: - HeaderInfo - -struct HeaderInfo { - - let visibilityMode: MagazineLayoutHeaderVisibilityMode - let title: String - -} - -// MARK: - FooterInfo - -struct FooterInfo { - - let visibilityMode: MagazineLayoutFooterVisibilityMode - let title: String - -} - -// MARK: - ItemInfo - -struct ItemInfo { - - let sizeMode: MagazineLayoutItemSizeMode - let text: String - let color: UIColor - -} - -// MARK: - BackgroundInfo - -struct BackgroundInfo { - - let visibilityMode: MagazineLayoutBackgroundVisibilityMode - -} diff --git a/Example/MagazineLayoutExample/Data/DataSource.swift b/Example/MagazineLayoutExample/Data/DataSource.swift deleted file mode 100644 index 9f206fc..0000000 --- a/Example/MagazineLayoutExample/Data/DataSource.swift +++ /dev/null @@ -1,129 +0,0 @@ -// Created by bryankeller on 11/28/18. -// Copyright © 2018 Airbnb, Inc. - -// 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 MagazineLayout -import UIKit - -// MARK: - DataSource - -final class DataSource: NSObject { - - private(set) var sectionInfos = [SectionInfo]() - - func insert(_ sectionInfo: SectionInfo, atSectionIndex sectionIndex: Int) { - sectionInfos.insert(sectionInfo, at: sectionIndex) - } - - func insert( - _ itemInfo: ItemInfo, - atItemIndex itemIndex: Int, - inSectionAtIndex sectionIndex: Int) - { - sectionInfos[sectionIndex].itemInfos.insert(itemInfo, at: itemIndex) - } - - func removeSection(atSectionIndex sectionIndex: Int) { - sectionInfos.remove(at: sectionIndex) - } - - func removeItem(atItemIndex itemIndex: Int, inSectionAtIndex sectionIndex: Int) { - sectionInfos[sectionIndex].itemInfos.remove(at: itemIndex) - } - - func setHeaderInfo(_ headerInfo: HeaderInfo, forSectionAtIndex sectionIndex: Int) { - sectionInfos[sectionIndex].headerInfo = headerInfo - } - - func setFooterInfo(_ footerInfo: FooterInfo, forSectionAtIndex sectionIndex: Int) { - sectionInfos[sectionIndex].footerInfo = footerInfo - } - -} - -// MARK: UICollectionViewDataSource - -extension DataSource: UICollectionViewDataSource { - - func numberOfSections(in collectionView: UICollectionView) -> Int { - return sectionInfos.count - } - - func collectionView( - _ collectionView: UICollectionView, - numberOfItemsInSection section: Int) - -> Int - { - return sectionInfos[section].itemInfos.count - } - - func collectionView( - _ collectionView: UICollectionView, - cellForItemAt indexPath: IndexPath) - -> UICollectionViewCell - { - let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: Cell.description(), - for: indexPath) as! Cell - let itemInfo = sectionInfos[indexPath.section].itemInfos[indexPath.item] - cell.set(itemInfo) - return cell - } - - func collectionView( - _ collectionView: UICollectionView, - viewForSupplementaryElementOfKind kind: String, - at indexPath: IndexPath) - -> UICollectionReusableView - { - switch kind { - case MagazineLayout.SupplementaryViewKind.sectionHeader: - let header = collectionView.dequeueReusableSupplementaryView( - ofKind: MagazineLayout.SupplementaryViewKind.sectionHeader, - withReuseIdentifier: Header.description(), - for: indexPath) as! Header - header.set(sectionInfos[indexPath.section].headerInfo) - return header - case MagazineLayout.SupplementaryViewKind.sectionFooter: - let header = collectionView.dequeueReusableSupplementaryView( - ofKind: MagazineLayout.SupplementaryViewKind.sectionFooter, - withReuseIdentifier: Footer.description(), - for: indexPath) as! Footer - header.set(sectionInfos[indexPath.section].footerInfo) - return header - case MagazineLayout.SupplementaryViewKind.sectionBackground: - return collectionView.dequeueReusableSupplementaryView( - ofKind: kind, - withReuseIdentifier: Background.description(), - for: indexPath) - default: - fatalError("Not supported") - } - } - -} - -// MARK: DataSourceCountsProvider - -extension DataSource: DataSourceCountsProvider { - - var numberOfSections: Int { - return sectionInfos.count - } - - func numberOfItemsInSection(withIndex sectionIndex: Int) -> Int { - return sectionInfos[sectionIndex].itemInfos.count - } - -} diff --git a/Example/MagazineLayoutExample/Data/DataSourceCountsProvider.swift b/Example/MagazineLayoutExample/Data/DataSourceCountsProvider.swift deleted file mode 100644 index f83de44..0000000 --- a/Example/MagazineLayoutExample/Data/DataSourceCountsProvider.swift +++ /dev/null @@ -1,21 +0,0 @@ -// Created by bryankeller on 11/30/18. -// Copyright © 2018 Airbnb, Inc. - -// 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. - -protocol DataSourceCountsProvider { - - var numberOfSections: Int { get } - func numberOfItemsInSection(withIndex sectionIndex: Int) -> Int - -} diff --git a/Example/MagazineLayoutExample/GridDemoViewController.swift b/Example/MagazineLayoutExample/GridDemoViewController.swift new file mode 100644 index 0000000..ddf3454 --- /dev/null +++ b/Example/MagazineLayoutExample/GridDemoViewController.swift @@ -0,0 +1,344 @@ +// Created by Bryan Keller on 11/4/25. +// Copyright © 2025 Airbnb, Inc. +// +// 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 MagazineLayout +import SwiftUI +import UIKit + +// MARK: - GridDemoViewController + +final class GridDemoViewController: UIViewController { + + // MARK: Internal + + override func viewDidLoad() { + super.viewDidLoad() + + title = "Grid Layout" + view.backgroundColor = .systemBackground + + navigationItem.rightBarButtonItems = [ + UIBarButtonItem( + barButtonSystemItem: .add, + target: self, + action: #selector(addButtonTapped)), + UIBarButtonItem( + image: UIImage(systemName: "shuffle"), + style: .plain, + target: self, + action: #selector(shuffleButtonTapped)), + ] + + view.addSubview(collectionView) + collectionView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + loadInitialData() + } + + // MARK: Private + + private typealias DataSource = UICollectionViewDiffableDataSource + private typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var collectionView: UICollectionView = { + let layout = MagazineLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .systemBackground + collectionView.delegate = self + return collectionView + }() + + private lazy var dataSource: DataSource = { + let cellRegistration = UICollectionView.CellRegistration + { cell, indexPath, item in + cell.contentConfiguration = UIHostingConfiguration { + GridItemView(item: item) + } + .margins(.all, 0) + } + + return DataSource( + collectionView: collectionView, + cellProvider: { collectionView, indexPath, item in + collectionView.dequeueConfiguredReusableCell( + using: cellRegistration, + for: indexPath, + item: item) + }) + }() + + private var items: [GridItem] = [] + + private func loadInitialData() { + let widthModes: [MagazineLayoutItemWidthMode] = [ + .fullWidth(respectsHorizontalInsets: true), + .halfWidth, + .halfWidth, + .thirdWidth, + .thirdWidth, + .thirdWidth, + .fractionalWidth(divisor: 4), + .fractionalWidth(divisor: 4), + .fractionalWidth(divisor: 4), + .fractionalWidth(divisor: 4), + .fractionalWidth(divisor: 5), + .fractionalWidth(divisor: 5), + .fractionalWidth(divisor: 5), + .fractionalWidth(divisor: 5), + .fractionalWidth(divisor: 5), + .fullWidth(respectsHorizontalInsets: true), + .thirdWidth, + .halfWidth, + ] + + items = widthModes.map { widthMode in + GridItem( + text: textForWidthMode(widthMode), + color: colorForWidthMode(widthMode), + widthMode: widthMode) + } + + applySnapshot(animatingDifferences: false) + } + + private func applySnapshot(animatingDifferences: Bool = true) { + var snapshot = Snapshot() + snapshot.appendSections([0]) + snapshot.appendItems(items, toSection: 0) + dataSource.apply(snapshot, animatingDifferences: animatingDifferences) + } + + private func colorForWidthMode(_ widthMode: MagazineLayoutItemWidthMode) -> UIColor { + switch widthMode { + case .fullWidth: + return .systemRed + case .halfWidth: + return .systemBlue + case .thirdWidth: + return .systemGreen + case let .fractionalWidth(divisor): + if divisor == 4 { + return .systemPurple + } else if divisor == 5 { + return .systemOrange + } else if divisor == 6 { + return .systemCyan + } else { + return .systemTeal + } + @unknown default: + return .systemRed + } + } + + private func textForWidthMode(_ widthMode: MagazineLayoutItemWidthMode) -> String { + switch widthMode { + case .fullWidth: + return "Full Width" + case .halfWidth: + return "Half Width" + case .thirdWidth: + return "Third Width" + case let .fractionalWidth(divisor): + if divisor == 4 { + return "Quarter Width" + } else if divisor == 5 { + return "1/5 Width" + } else { + return "1/\(divisor) Width" + } + @unknown default: + return "Unknown Width" + } + } + + @objc + private func addButtonTapped() { + let widthModes: [MagazineLayoutItemWidthMode] = [ + .fullWidth(respectsHorizontalInsets: true), + .halfWidth, + .thirdWidth, + .fractionalWidth(divisor: 4), + .fractionalWidth(divisor: 5), + ] + + let selectedWidthMode = widthModes.randomElement() ?? .halfWidth + + let newItem = GridItem( + text: textForWidthMode(selectedWidthMode), + color: colorForWidthMode(selectedWidthMode), + widthMode: selectedWidthMode) + + let insertIndex = Int.random(in: 0...items.count) + items.insert(newItem, at: insertIndex) + + applySnapshot() + } + + @objc + private func shuffleButtonTapped() { + items.shuffle() + applySnapshot() + } +} + +// MARK: UICollectionViewDelegate + +extension GridDemoViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + items.remove(at: indexPath.item) + applySnapshot() + } +} + +// MARK: UICollectionViewDelegateMagazineLayout + +extension GridDemoViewController: UICollectionViewDelegateMagazineLayout { + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeModeForItemAt indexPath: IndexPath) + -> MagazineLayoutItemSizeMode + { + return MagazineLayoutItemSizeMode( + widthMode: items[indexPath.item].widthMode, + heightMode: .dynamic) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForHeaderInSectionAtIndex index: Int) + -> MagazineLayoutHeaderVisibilityMode + { + .hidden + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForFooterInSectionAtIndex index: Int) + -> MagazineLayoutFooterVisibilityMode + { + .hidden + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForBackgroundInSectionAtIndex index: Int) + -> MagazineLayoutBackgroundVisibilityMode + { + .hidden + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + horizontalSpacingForItemsInSectionAtIndex index: Int) + -> CGFloat + { + 12 + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + verticalSpacingForElementsInSectionAtIndex index: Int) + -> CGFloat + { + 12 + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + insetsForSectionAtIndex index: Int) + -> UIEdgeInsets + { + UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + insetsForItemsInSectionAtIndex index: Int) + -> UIEdgeInsets + { + .zero + } +} + +// MARK: - GridItem + +private struct GridItem: Hashable { + + // MARK: Lifecycle + + init( + id: UUID = UUID(), + text: String, + color: UIColor, + widthMode: MagazineLayoutItemWidthMode) + { + self.id = id + self.text = text + self.color = color + self.widthMode = widthMode + } + + // MARK: Internal + + let id: UUID + let text: String + let color: UIColor + let widthMode: MagazineLayoutItemWidthMode + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: GridItem, rhs: GridItem) -> Bool { + lhs.id == rhs.id + } +} + +// MARK: - GridItemView + +private struct GridItemView: View { + let item: GridItem + + var body: some View { + VStack { + Text(item.text) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, minHeight: 80) + .padding(16) + .background(Color(item.color)) + .cornerRadius(12) + } +} diff --git a/Example/MagazineLayoutExample/Info.plist b/Example/MagazineLayoutExample/Info.plist index 4222ac2..a5c2d22 100644 --- a/Example/MagazineLayoutExample/Info.plist +++ b/Example/MagazineLayoutExample/Info.plist @@ -20,6 +20,23 @@ 1 LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities diff --git a/Example/MagazineLayoutExample/ListDemoViewController.swift b/Example/MagazineLayoutExample/ListDemoViewController.swift new file mode 100644 index 0000000..2e33247 --- /dev/null +++ b/Example/MagazineLayoutExample/ListDemoViewController.swift @@ -0,0 +1,488 @@ +// Created by Bryan Keller on 11/4/25. +// Copyright © 2025 Airbnb, Inc. +// +// 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 MagazineLayout +import SwiftUI +import UIKit + +// MARK: - ListDemoViewController + +final class ListDemoViewController: UIViewController { + + // MARK: Internal + + override func viewDidLoad() { + super.viewDidLoad() + + title = "List Layout" + view.backgroundColor = .systemBackground + + navigationItem.rightBarButtonItems = [ + UIBarButtonItem( + barButtonSystemItem: .add, + target: self, + action: #selector(addButtonTapped)), + UIBarButtonItem( + image: UIImage(systemName: "shuffle"), + style: .plain, + target: self, + action: #selector(shuffleButtonTapped)), + ] + + view.addSubview(collectionView) + collectionView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + loadInitialData() + } + + // MARK: Private + + private typealias DataSource = UICollectionViewDiffableDataSource + private typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var collectionView: UICollectionView = { + let layout = MagazineLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .systemBackground + collectionView.delegate = self + return collectionView + }() + + private lazy var dataSource: DataSource = { + let cellRegistration = UICollectionView.CellRegistration + { cell, indexPath, item in + cell.contentConfiguration = UIHostingConfiguration { + ListItemView(item: item) + } + .margins(.all, 0) + } + + let headerRegistration = UICollectionView.SupplementaryRegistration( + elementKind: MagazineLayout.SupplementaryViewKind.sectionHeader + ) { [weak self] supplementaryView, elementKind, indexPath in + guard let self, indexPath.section < self.sections.count else { return } + + let section = self.sections[indexPath.section] + supplementaryView.contentConfiguration = UIHostingConfiguration { + SectionHeaderView(title: section.title) + } + .margins(.all, 0) + } + + let footerRegistration = UICollectionView.SupplementaryRegistration( + elementKind: MagazineLayout.SupplementaryViewKind.sectionFooter + ) { supplementaryView, elementKind, indexPath in + supplementaryView.contentConfiguration = UIHostingConfiguration { + SectionFooterView() + } + .margins(.all, 0) + } + + dataSource = DataSource( + collectionView: collectionView, + cellProvider: { collectionView, indexPath, item in + collectionView.dequeueConfiguredReusableCell( + using: cellRegistration, + for: indexPath, + item: item) + }) + + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in + switch kind { + case MagazineLayout.SupplementaryViewKind.sectionHeader: + return collectionView.dequeueConfiguredReusableSupplementary( + using: headerRegistration, + for: indexPath) + case MagazineLayout.SupplementaryViewKind.sectionFooter: + return collectionView.dequeueConfiguredReusableSupplementary( + using: footerRegistration, + for: indexPath) + default: + return nil + } + } + + return dataSource + }() + + private var sections: [ListSection] = [] + + private func loadInitialData() { + sections = [ + ListSection( + title: "Featured Items", + items: [ + ListItem( + title: "Item 1", + subtitle: "A featured item with important content", + color: .systemRed), + ListItem( + title: "Item 2", + subtitle: "Another featured item to showcase", + color: .systemBlue), + ListItem( + title: "Item 3", + subtitle: "The third item in this section", + color: .systemGreen), + ], + headerPinned: true, + footerPinned: false), + ListSection( + title: "Regular Items", + items: [ + ListItem( + title: "Item A", + subtitle: "A regular item in the list", + color: .systemPurple), + ListItem( + title: "Item B", + subtitle: "Another regular item", + color: .systemCyan), + ListItem( + title: "Item C", + subtitle: "Yet another item", + color: .systemOrange), + ListItem( + title: "Item D", + subtitle: "More content here", + color: .systemTeal), + ], + headerPinned: true, + footerPinned: false), + ListSection( + title: "Special Section", + items: [ + ListItem( + title: "Special 1", + subtitle: "This section has a pinned footer", + color: .systemPink), + ListItem( + title: "Special 2", + subtitle: "Notice the footer sticks", + color: .systemYellow), + ], + headerPinned: true, + footerPinned: true), + ] + + applySnapshot(animatingDifferences: false) + } + + private func applySnapshot(animatingDifferences: Bool = true) { + var snapshot = Snapshot() + for section in sections { + snapshot.appendSections([section.id]) + snapshot.appendItems(section.items, toSection: section.id) + } + dataSource.apply(snapshot, animatingDifferences: animatingDifferences) + } + + @objc + private func addButtonTapped() { + guard !sections.isEmpty else { return } + + let randomSectionIndex = Int.random(in: 0.. Int? { + sections.firstIndex { $0.id == sectionId } + } +} + +// MARK: UICollectionViewDelegate + +extension ListDemoViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let section = sections[indexPath.section] + + var updatedItems = section.items + updatedItems.remove(at: indexPath.item) + + sections[indexPath.section] = ListSection( + id: section.id, + title: section.title, + items: updatedItems, + headerPinned: section.headerPinned, + footerPinned: section.footerPinned) + + applySnapshot() + } +} + +// MARK: UICollectionViewDelegateMagazineLayout + +extension ListDemoViewController: UICollectionViewDelegateMagazineLayout { + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeModeForItemAt indexPath: IndexPath) + -> MagazineLayoutItemSizeMode + { + MagazineLayoutItemSizeMode( + widthMode: .fullWidth(respectsHorizontalInsets: true), + heightMode: .dynamic) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForHeaderInSectionAtIndex index: Int) + -> MagazineLayoutHeaderVisibilityMode + { + let section = sections[index] + return .visible(heightMode: .dynamic, pinToVisibleBounds: section.headerPinned) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForFooterInSectionAtIndex index: Int) + -> MagazineLayoutFooterVisibilityMode + { + let section = sections[index] + return .visible(heightMode: .dynamic, pinToVisibleBounds: section.footerPinned) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForBackgroundInSectionAtIndex index: Int) + -> MagazineLayoutBackgroundVisibilityMode + { + .hidden + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + horizontalSpacingForItemsInSectionAtIndex index: Int) + -> CGFloat + { + 12 + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + verticalSpacingForElementsInSectionAtIndex index: Int) + -> CGFloat + { + 12 + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + insetsForSectionAtIndex index: Int) + -> UIEdgeInsets + { + UIEdgeInsets(top: 0, left: 0, bottom: 24, right: 0) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + insetsForItemsInSectionAtIndex index: Int) + -> UIEdgeInsets + { + UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + } +} + +// MARK: - ListSection + +private struct ListSection: Hashable { + + // MARK: Lifecycle + + init( + id: UUID = UUID(), + title: String, + items: [ListItem], + headerPinned: Bool = true, + footerPinned: Bool = false) + { + self.id = id + self.title = title + self.items = items + self.headerPinned = headerPinned + self.footerPinned = footerPinned + } + + // MARK: Internal + + let id: UUID + let title: String + let items: [ListItem] + let headerPinned: Bool + let footerPinned: Bool + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: ListSection, rhs: ListSection) -> Bool { + lhs.id == rhs.id + } +} + +// MARK: - ListItem + +private struct ListItem: Hashable { + + // MARK: Lifecycle + + init( + id: UUID = UUID(), + title: String, + subtitle: String, + color: UIColor) + { + self.id = id + self.title = title + self.subtitle = subtitle + self.color = color + } + + // MARK: Internal + + let id: UUID + let title: String + let subtitle: String + let color: UIColor + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: ListItem, rhs: ListItem) -> Bool { + lhs.id == rhs.id + } +} + +// MARK: - ListItemView + +private struct ListItemView: View { + let item: ListItem + + var body: some View { + HStack(spacing: 16) { + RoundedRectangle(cornerRadius: 8) + .fill(Color(item.color)) + .frame(width: 60, height: 60) + + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(.headline) + .foregroundColor(.primary) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + + Text(item.subtitle) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + } + .padding(16) + .background(Color(UIColor.secondarySystemBackground)) + .cornerRadius(12) + } +} + +// MARK: - SectionHeaderView + +private struct SectionHeaderView: View { + let title: String + + var body: some View { + HStack { + Text(title) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(.primary) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color(UIColor.systemBackground).opacity(0.95)) + } +} + +// MARK: - SectionFooterView + +private struct SectionFooterView: View { + var body: some View { + HStack { + Spacer() + + Text("Section footer") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color(UIColor.systemBackground).opacity(0.95)) + } +} diff --git a/Example/MagazineLayoutExample/MessageThreadDemoViewController.swift b/Example/MagazineLayoutExample/MessageThreadDemoViewController.swift new file mode 100644 index 0000000..bd0c8d2 --- /dev/null +++ b/Example/MagazineLayoutExample/MessageThreadDemoViewController.swift @@ -0,0 +1,365 @@ +// Created by Bryan Keller on 11/4/25. +// Copyright © 2025 Airbnb, Inc. +// +// 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 MagazineLayout +import SwiftUI +import UIKit + +// MARK: - MessageThreadDemoViewController + +final class MessageThreadDemoViewController: UIViewController { + + // MARK: Internal + + override func viewDidLoad() { + super.viewDidLoad() + + title = "Message Thread" + view.backgroundColor = .systemBackground + + navigationItem.rightBarButtonItems = [ + UIBarButtonItem( + title: "Send", + style: .plain, + target: self, + action: #selector(sendButtonTapped)), + UIBarButtonItem( + title: "Receive", + style: .plain, + target: self, + action: #selector(receiveButtonTapped)), + ] + + view.addSubview(collectionView) + collectionView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + loadInitialMessages() + } + + // MARK: Private + + private typealias DataSource = UICollectionViewDiffableDataSource + private typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var collectionView: UICollectionView = { + let layout = MagazineLayout() + layout.verticalLayoutDirection = .bottomToTop + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .systemBackground + collectionView.delegate = self + return collectionView + }() + + private lazy var dataSource: DataSource = { + let cellRegistration = UICollectionView.CellRegistration + { cell, indexPath, message in + cell.contentConfiguration = UIHostingConfiguration { + MessageView(message: message) + } + .margins(.all, 0) + } + + return DataSource( + collectionView: collectionView, + cellProvider: { collectionView, indexPath, message in + collectionView.dequeueConfiguredReusableCell( + using: cellRegistration, + for: indexPath, + item: message) + }) + }() + + private var messages: [Message] = [] + private var messageCounter = 0 + private var oldestMessageDate = Date() + private var isLoadingMore = false + + private let sentMessages = [ + "Hey, how are you?", + "That sounds great!", + "I'm on my way", + "See you soon!", + "Thanks for letting me know", + "Perfect timing", + "Can't wait to see it", + "Definitely interested", + "Count me in!", + "That works for me", + ] + + private let receivedMessages = [ + "Good! How about you?", + "I know, right?", + "Great! I'll be here", + "Looking forward to it!", + "No problem at all", + "Glad it works out", + "Me too!", + "Awesome, thanks!", + "Cool, see you there", + "Sounds like a plan", + ] + + private func loadInitialMessages() { + // Create 10 initial messages alternating between sent and received + var initialMessages: [Message] = [] + var timestamp = Date() + + for i in 0..<20 { + let isSent = i % 2 == 0 + let text = isSent + ? sentMessages[i % sentMessages.count] + : receivedMessages[i % receivedMessages.count] + + initialMessages.append(Message( + text: text, + isSent: isSent, + timestamp: timestamp)) + + timestamp = timestamp.addingTimeInterval(-60) // 1 minute earlier + } + + messages = initialMessages.reversed() + oldestMessageDate = messages.first?.timestamp ?? Date() + messageCounter = messages.count + + applySnapshot(animatingDifferences: false) + } + + private func applySnapshot(animatingDifferences: Bool = true) { + var snapshot = Snapshot() + snapshot.appendSections([0]) + snapshot.appendItems(messages, toSection: 0) + dataSource.apply(snapshot, animatingDifferences: animatingDifferences) + } + + private func loadOlderMessages() { + guard !isLoadingMore else { return } + isLoadingMore = true + + // Simulate loading 10 older messages + var olderMessages: [Message] = [] + var timestamp = oldestMessageDate.addingTimeInterval(-60) + + for i in 0..<10 { + let isSent = (messageCounter + i) % 2 == 0 + let text = isSent + ? sentMessages[(messageCounter + i) % sentMessages.count] + : receivedMessages[(messageCounter + i) % receivedMessages.count] + + olderMessages.append(Message( + text: text, + isSent: isSent, + timestamp: timestamp)) + + timestamp = timestamp.addingTimeInterval(-60) + } + + // Prepend older messages + messages.insert(contentsOf: olderMessages.reversed(), at: 0) + oldestMessageDate = messages.first?.timestamp ?? Date() + messageCounter += 10 + + applySnapshot() + isLoadingMore = false + } + + @objc + private func sendButtonTapped() { + let newMessage = Message( + text: sentMessages.randomElement() ?? "Hello!", + isSent: true) + + messages.append(newMessage) + messageCounter += 1 + + applySnapshot() + } + + @objc + private func receiveButtonTapped() { + let newMessage = Message( + text: receivedMessages.randomElement() ?? "Hi there!", + isSent: false) + + messages.append(newMessage) + messageCounter += 1 + + applySnapshot() + } +} + +// MARK: UICollectionViewDelegate + +extension MessageThreadDemoViewController: UICollectionViewDelegate { + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + if scrollView.contentOffset.y <= -scrollView.adjustedContentInset.top && !isLoadingMore { + loadOlderMessages() + } + } +} + +// MARK: UICollectionViewDelegateMagazineLayout + +extension MessageThreadDemoViewController: UICollectionViewDelegateMagazineLayout { + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeModeForItemAt indexPath: IndexPath) + -> MagazineLayoutItemSizeMode + { + MagazineLayoutItemSizeMode( + widthMode: .fullWidth(respectsHorizontalInsets: true), + heightMode: .dynamic) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForHeaderInSectionAtIndex index: Int) + -> MagazineLayoutHeaderVisibilityMode + { + .hidden + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForFooterInSectionAtIndex index: Int) + -> MagazineLayoutFooterVisibilityMode + { + .hidden + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForBackgroundInSectionAtIndex index: Int) + -> MagazineLayoutBackgroundVisibilityMode + { + .hidden + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + horizontalSpacingForItemsInSectionAtIndex index: Int) + -> CGFloat + { + 12 + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + verticalSpacingForElementsInSectionAtIndex index: Int) + -> CGFloat + { + 8 + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + insetsForSectionAtIndex index: Int) + -> UIEdgeInsets + { + UIEdgeInsets(top: 16, left: 0, bottom: 16, right: 0) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + insetsForItemsInSectionAtIndex index: Int) + -> UIEdgeInsets + { + UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + } +} + +// MARK: - Message + +private struct Message: Hashable { + + // MARK: Lifecycle + + init( + id: UUID = UUID(), + text: String, + isSent: Bool, + timestamp: Date = Date()) + { + self.id = id + self.text = text + self.isSent = isSent + self.timestamp = timestamp + } + + // MARK: Internal + + let id: UUID + let text: String + let isSent: Bool + let timestamp: Date + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: Message, rhs: Message) -> Bool { + lhs.id == rhs.id + } +} + +// MARK: - MessageView + +private struct MessageView: View { + let message: Message + + var body: some View { + HStack { + if message.isSent { + Spacer() + } + + VStack(alignment: message.isSent ? .trailing : .leading, spacing: 4) { + Text(message.text) + .font(.body) + .foregroundColor(.white) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + + Text(message.timestamp.formatted(date: .omitted, time: .shortened)) + .font(.caption2) + .foregroundColor(.white.opacity(0.7)) + } + .padding(12) + .background(message.isSent ? Color.blue : Color.gray) + .cornerRadius(16) + .frame(maxWidth: 280, alignment: message.isSent ? .trailing : .leading) + + if !message.isSent { + Spacer() + } + } + .frame(maxWidth: .infinity) + } +} diff --git a/Example/MagazineLayoutExample/PerformanceDemoViewController.swift b/Example/MagazineLayoutExample/PerformanceDemoViewController.swift new file mode 100644 index 0000000..cd3b0ad --- /dev/null +++ b/Example/MagazineLayoutExample/PerformanceDemoViewController.swift @@ -0,0 +1,251 @@ +// Created by Bryan Keller on 11/5/25. +// Copyright © 2025 Airbnb, Inc. +// +// 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 MagazineLayout +import SwiftUI +import UIKit + +// MARK: - PerformanceDemoViewController + +final class PerformanceDemoViewController: UIViewController { + + // MARK: Internal + + override func viewDidLoad() { + super.viewDidLoad() + + title = "Performance (10K Items)" + view.backgroundColor = .systemBackground + + navigationItem.rightBarButtonItems = [ + UIBarButtonItem( + barButtonSystemItem: .add, + target: self, + action: #selector(addButtonTapped)), + ] + + view.addSubview(collectionView) + collectionView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + loadInitialData() + } + + // MARK: Private + + private lazy var collectionView: UICollectionView = { + let layout = MagazineLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .systemBackground + collectionView.dataSource = self + collectionView.delegate = self + collectionView.register( + MagazineLayoutCollectionViewCell.self, + forCellWithReuseIdentifier: "PerformanceCell") + return collectionView + }() + + private var items: [PerformanceItem] = [] + private var nextItemID = 0 + + private let colors: [UIColor] = [ + .systemRed, .systemOrange, .systemYellow, .systemGreen, .systemTeal, + .systemBlue, .systemIndigo, .systemPurple, .systemPink, .systemCyan + ] + + private func loadInitialData() { + // Create 10,000 items + items = (0..<10_000).map { index in + let color = colors[index % colors.count] + let item = PerformanceItem(id: nextItemID, color: color) + nextItemID += 1 + return item + } + + collectionView.reloadData() + } + + @objc + private func addButtonTapped() { + let newItem = PerformanceItem( + id: nextItemID, + color: colors.randomElement() ?? .systemBlue) + nextItemID += 1 + + // Insert at index 0 with manual batch update + collectionView.performBatchUpdates({ + items.insert(newItem, at: 0) + collectionView.insertItems(at: [IndexPath(item: 0, section: 0)]) + }, completion: nil) + } +} + +// MARK: UICollectionViewDataSource + +extension PerformanceDemoViewController: UICollectionViewDataSource { + func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } + + func collectionView( + _ collectionView: UICollectionView, + numberOfItemsInSection section: Int) + -> Int + { + return items.count + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath) + -> UICollectionViewCell + { + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: "PerformanceCell", + for: indexPath) + + let item = items[indexPath.item] + + cell.contentConfiguration = UIHostingConfiguration { + PerformanceItemView(item: item) + } + .margins(.all, 0) + + return cell + } +} + +// MARK: UICollectionViewDelegate + +extension PerformanceDemoViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard indexPath.item < items.count else { return } + + // Delete with manual batch update + collectionView.performBatchUpdates({ + items.remove(at: indexPath.item) + collectionView.deleteItems(at: [indexPath]) + }, completion: nil) + } +} + +// MARK: UICollectionViewDelegateMagazineLayout + +extension PerformanceDemoViewController: UICollectionViewDelegateMagazineLayout { + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeModeForItemAt indexPath: IndexPath) + -> MagazineLayoutItemSizeMode + { + MagazineLayoutItemSizeMode(widthMode: .halfWidth, heightMode: .dynamic) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForHeaderInSectionAtIndex index: Int) + -> MagazineLayoutHeaderVisibilityMode + { + .hidden + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForFooterInSectionAtIndex index: Int) + -> MagazineLayoutFooterVisibilityMode + { + .hidden + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForBackgroundInSectionAtIndex index: Int) + -> MagazineLayoutBackgroundVisibilityMode + { + .hidden + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + horizontalSpacingForItemsInSectionAtIndex index: Int) + -> CGFloat + { + 12 + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + verticalSpacingForElementsInSectionAtIndex index: Int) + -> CGFloat + { + 12 + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + insetsForSectionAtIndex index: Int) + -> UIEdgeInsets + { + UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + insetsForItemsInSectionAtIndex index: Int) + -> UIEdgeInsets + { + .zero + } +} + +// MARK: - PerformanceItem + +private struct PerformanceItem { + let id: Int + let color: UIColor +} + +// MARK: - PerformanceItemView + +private struct PerformanceItemView: View { + let item: PerformanceItem + + var body: some View { + VStack { + Text("Item \(item.id)") + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.white) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, minHeight: 80) + .padding(16) + .background(Color(item.color)) + .cornerRadius(12) + } +} diff --git a/Example/MagazineLayoutExample/RootMenuViewController.swift b/Example/MagazineLayoutExample/RootMenuViewController.swift new file mode 100644 index 0000000..7b5cf31 --- /dev/null +++ b/Example/MagazineLayoutExample/RootMenuViewController.swift @@ -0,0 +1,244 @@ +// Created by Bryan Keller on 11/4/25. +// Copyright © 2025 Airbnb, Inc. +// +// 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 MagazineLayout +import SwiftUI +import UIKit + +// MARK: - RootMenuViewController + +final class RootMenuViewController: UIViewController { + + // MARK: Internal + + override func viewDidLoad() { + super.viewDidLoad() + + title = "MagazineLayout Demos" + view.backgroundColor = .systemBackground + + view.addSubview(collectionView) + collectionView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + applyInitialSnapshot() + } + + // MARK: Private + + private lazy var collectionView: UICollectionView = { + let layout = MagazineLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .systemBackground + collectionView.delegate = self + return collectionView + }() + + private typealias DataSource = UICollectionViewDiffableDataSource + private typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource: DataSource = { + let cellRegistration = UICollectionView.CellRegistration + { cell, indexPath, option in + cell.contentConfiguration = UIHostingConfiguration { + MenuItemView(option: option) + } + .margins(.all, 0) + } + + return DataSource( + collectionView: collectionView, + cellProvider: { collectionView, indexPath, option in + collectionView.dequeueConfiguredReusableCell( + using: cellRegistration, + for: indexPath, + item: option) + }) + }() + + private func applyInitialSnapshot() { + var snapshot = Snapshot() + snapshot.appendSections([0]) + snapshot.appendItems(DemoOption.allCases, toSection: 0) + dataSource.apply(snapshot, animatingDifferences: false) + } +} + +// MARK: UICollectionViewDelegate + +extension RootMenuViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let option = dataSource.itemIdentifier(for: indexPath) else { return } + + let viewController: UIViewController + switch option { + case .grid: + viewController = GridDemoViewController() + case .list: + viewController = ListDemoViewController() + case .messageThread: + viewController = MessageThreadDemoViewController() + case .performance: + viewController = PerformanceDemoViewController() + } + + navigationController?.pushViewController(viewController, animated: true) + } +} + +// MARK: UICollectionViewDelegateMagazineLayout + +extension RootMenuViewController: UICollectionViewDelegateMagazineLayout { + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeModeForItemAt indexPath: IndexPath) + -> MagazineLayoutItemSizeMode + { + MagazineLayoutItemSizeMode( + widthMode: .fullWidth(respectsHorizontalInsets: true), + heightMode: .dynamic) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForHeaderInSectionAtIndex index: Int) + -> MagazineLayoutHeaderVisibilityMode + { + .hidden + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForFooterInSectionAtIndex index: Int) + -> MagazineLayoutFooterVisibilityMode + { + .hidden + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForBackgroundInSectionAtIndex index: Int) + -> MagazineLayoutBackgroundVisibilityMode + { + .hidden + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + horizontalSpacingForItemsInSectionAtIndex index: Int) + -> CGFloat + { + 16 + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + verticalSpacingForElementsInSectionAtIndex index: Int) + -> CGFloat + { + 16 + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + insetsForSectionAtIndex index: Int) + -> UIEdgeInsets + { + UIEdgeInsets(top: 16, left: 0, bottom: 16, right: 0) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + insetsForItemsInSectionAtIndex index: Int) + -> UIEdgeInsets + { + UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + } +} + +// MARK: - DemoOption + +private enum DemoOption: String, CaseIterable { + case grid = "Grid Layout" + case list = "List Layout" + case messageThread = "Message Thread" + case performance = "Performance" + + var subtitle: String { + switch self { + case .grid: + return "Various width modes and flexible layouts" + case .list: + return "Full-width items with pinned headers & footers" + case .messageThread: + return "Bottom-to-top layout with pagination" + case .performance: + return "10,000 items with traditional data source" + } + } + + var color: UIColor { + switch self { + case .grid: + return .systemBlue + case .list: + return .systemRed + case .messageThread: + return .systemPurple + case .performance: + return .systemGreen + } + } +} + +// MARK: - MenuItemView + +private struct MenuItemView: View { + let option: DemoOption + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(option.rawValue) + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.white) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + + Text(option.subtitle) + .font(.subheadline) + .foregroundColor(.white.opacity(0.9)) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(24) + .background(Color(option.color)) + .cornerRadius(16) + } +} diff --git a/Example/MagazineLayoutExample/SceneDelegate.swift b/Example/MagazineLayoutExample/SceneDelegate.swift new file mode 100644 index 0000000..b357d7d --- /dev/null +++ b/Example/MagazineLayoutExample/SceneDelegate.swift @@ -0,0 +1,60 @@ +// Created by Bryan Keller on 11/4/25. +// Copyright © 2025 Airbnb, Inc. +// +// 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 + +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + // MARK: Internal + + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) + { + guard let windowScene = (scene as? UIWindowScene) else { return } + + window = UIWindow(windowScene: windowScene) + + let rootViewController = RootMenuViewController() + let navigationController = UINavigationController(rootViewController: rootViewController) + navigationController.navigationBar.prefersLargeTitles = false + + window?.rootViewController = navigationController + window?.makeKeyAndVisible() + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + } +} diff --git a/Example/MagazineLayoutExample/ViewController.swift b/Example/MagazineLayoutExample/ViewController.swift deleted file mode 100644 index 3079b50..0000000 --- a/Example/MagazineLayoutExample/ViewController.swift +++ /dev/null @@ -1,524 +0,0 @@ -// Created by bryankeller on 11/28/18. -// Copyright © 2018 Airbnb, Inc. - -// 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 MagazineLayout -import UIKit - -// MARK: - ViewController - -final class ViewController: UIViewController { - - // MARK: Internal - - override func viewDidLoad() { - super.viewDidLoad() - - navigationItem.title = "MagazineLayout Example" - navigationItem.rightBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .add, - target: self, - action: #selector(addButtonTapped)) - - let reloadDataButton = UIBarButtonItem( - barButtonSystemItem: .refresh, - target: self, - action: #selector(reloadButtonTapped)) - navigationItem.leftBarButtonItem = reloadDataButton - - view.addSubview(collectionView) - - collectionView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - collectionView.topAnchor.constraint(equalTo: view.topAnchor), - collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - - loadDefaultData() - } - - // MARK: Private - - private lazy var collectionView: UICollectionView = { - let layout = MagazineLayout() - let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) - collectionView.register(Cell.self, forCellWithReuseIdentifier: Cell.description()) - collectionView.register( - Header.self, - forSupplementaryViewOfKind: MagazineLayout.SupplementaryViewKind.sectionHeader, - withReuseIdentifier: Header.description()) - collectionView.register( - Footer.self, - forSupplementaryViewOfKind: MagazineLayout.SupplementaryViewKind.sectionFooter, - withReuseIdentifier: Footer.description()) - collectionView.register( - Background.self, - forSupplementaryViewOfKind: MagazineLayout.SupplementaryViewKind.sectionBackground, - withReuseIdentifier: Background.description()) - collectionView.isPrefetchingEnabled = false - collectionView.dataSource = dataSource - collectionView.delegate = self - collectionView.backgroundColor = .white - collectionView.contentInsetAdjustmentBehavior = .always - return collectionView - }() - - private lazy var dataSource = DataSource() - - #if os(iOS) - private var lastItemCreationPanelViewState: ItemCreationPanelViewState? - #endif - - private func removeAllData() { - collectionView.performBatchUpdates({ - for sectionIndex in (0.. 1 { - dataSource.removeItem(atItemIndex: indexPath.item, inSectionAtIndex: indexPath.section) - collectionView.deleteItems(at: [indexPath]) - } else { - dataSource.removeSection(atSectionIndex: indexPath.section) - collectionView.deleteSections(IndexSet(integer: indexPath.section)) - } - }, completion: nil) - } - -} - -// MARK: UICollectionViewDelegateMagazineLayout - -extension ViewController: UICollectionViewDelegateMagazineLayout { - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeModeForItemAt indexPath: IndexPath) - -> MagazineLayoutItemSizeMode - { - return dataSource.sectionInfos[indexPath.section].itemInfos[indexPath.item].sizeMode - } - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - visibilityModeForHeaderInSectionAtIndex index: Int) - -> MagazineLayoutHeaderVisibilityMode - { - return dataSource.sectionInfos[index].headerInfo.visibilityMode - } - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - visibilityModeForFooterInSectionAtIndex index: Int) - -> MagazineLayoutFooterVisibilityMode - { - return dataSource.sectionInfos[index].footerInfo.visibilityMode - } - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - visibilityModeForBackgroundInSectionAtIndex index: Int) - -> MagazineLayoutBackgroundVisibilityMode - { - return dataSource.sectionInfos[index].backgroundInfo.visibilityMode - } - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - horizontalSpacingForItemsInSectionAtIndex index: Int) - -> CGFloat - { - return 12 - } - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - verticalSpacingForElementsInSectionAtIndex index: Int) - -> CGFloat - { - return 12 - } - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - insetsForSectionAtIndex index: Int) - -> UIEdgeInsets - { - return UIEdgeInsets(top: 24, left: 4, bottom: 24, right: 4) - } - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - insetsForItemsInSectionAtIndex index: Int) - -> UIEdgeInsets - { - return UIEdgeInsets(top: 24, left: 4, bottom: 24, right: 4) - } - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - finalLayoutAttributesForRemovedItemAt indexPath: IndexPath, - byModifying finalLayoutAttributes: UICollectionViewLayoutAttributes) - { - // Fade and drop out - finalLayoutAttributes.alpha = 0 - finalLayoutAttributes.transform = .init(scaleX: 0.2, y: 0.2) - } - -} diff --git a/Example/MagazineLayoutExample/Views/Background.swift b/Example/MagazineLayoutExample/Views/Background.swift deleted file mode 100644 index 206b78a..0000000 --- a/Example/MagazineLayoutExample/Views/Background.swift +++ /dev/null @@ -1,32 +0,0 @@ -// Created by nate-sentjens on 4/8/19. -// Copyright © 2019 Airbnb, Inc. - -// 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 MagazineLayout -import UIKit - -final class Background: MagazineLayoutCollectionReusableView { - - // MARK: Lifecycle - - override init(frame: CGRect) { - super.init(frame: frame) - - backgroundColor = UIColor(hue: 0, saturation: 0, brightness: 0.6, alpha: 1) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/Example/MagazineLayoutExample/Views/Cell.swift b/Example/MagazineLayoutExample/Views/Cell.swift deleted file mode 100644 index 6f31fab..0000000 --- a/Example/MagazineLayoutExample/Views/Cell.swift +++ /dev/null @@ -1,64 +0,0 @@ -// Created by bryankeller on 11/30/18. -// Copyright © 2018 Airbnb, Inc. - -// 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 MagazineLayout -import UIKit - -final class Cell: MagazineLayoutCollectionViewCell { - - // MARK: Lifecycle - - override init(frame: CGRect) { - label = UILabel(frame: .zero) - - super.init(frame: frame) - - label.font = UIFont.systemFont(ofSize: 24) - label.textColor = .white - label.numberOfLines = 0 - contentView.addSubview(label) - - label.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 4), - label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -4), - label.topAnchor.constraint(equalTo: contentView.topAnchor), - label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - ]) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Internal - - override func prepareForReuse() { - super.prepareForReuse() - - label.text = nil - contentView.backgroundColor = nil - } - - func set(_ itemInfo: ItemInfo) { - label.text = itemInfo.text - contentView.backgroundColor = itemInfo.color - } - - // MARK: Private - - private let label: UILabel - -} diff --git a/Example/MagazineLayoutExample/Views/Footer.swift b/Example/MagazineLayoutExample/Views/Footer.swift deleted file mode 100644 index d08bb24..0000000 --- a/Example/MagazineLayoutExample/Views/Footer.swift +++ /dev/null @@ -1,63 +0,0 @@ -// Created by Roman Laitarenko on 2/4/19. - -// 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 MagazineLayout -import UIKit - -final class Footer: MagazineLayoutCollectionReusableView { - - // MARK: Lifecycle - - override init(frame: CGRect) { - label = UILabel(frame: .zero) - - super.init(frame: frame) - - backgroundColor = UIColor(hue: 0, saturation: 0, brightness: 0.95, alpha: 1) - - label.font = UIFont.systemFont(ofSize: 48) - label.textColor = .black - label.numberOfLines = 0 - addSubview(label) - - label.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4), - label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), - label.topAnchor.constraint(equalTo: topAnchor), - label.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Internal - - override func prepareForReuse() { - super.prepareForReuse() - - label.text = nil - } - - func set(_ footerInfo: FooterInfo) { - label.text = footerInfo.title - } - - // MARK: Private - - private let label: UILabel - -} diff --git a/Example/MagazineLayoutExample/Views/Header.swift b/Example/MagazineLayoutExample/Views/Header.swift deleted file mode 100644 index 236870e..0000000 --- a/Example/MagazineLayoutExample/Views/Header.swift +++ /dev/null @@ -1,64 +0,0 @@ -// Created by bryankeller on 11/30/18. -// Copyright © 2018 Airbnb, Inc. - -// 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 MagazineLayout -import UIKit - -final class Header: MagazineLayoutCollectionReusableView { - - // MARK: Lifecycle - - override init(frame: CGRect) { - label = UILabel(frame: .zero) - - super.init(frame: frame) - - backgroundColor = UIColor(hue: 0, saturation: 0, brightness: 0.95, alpha: 1) - - label.font = UIFont.systemFont(ofSize: 48) - label.textColor = .black - label.numberOfLines = 0 - addSubview(label) - - label.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4), - label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), - label.topAnchor.constraint(equalTo: topAnchor), - label.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Internal - - override func prepareForReuse() { - super.prepareForReuse() - - label.text = nil - } - - func set(_ headerInfo: HeaderInfo) { - label.text = headerInfo.title - } - - // MARK: Private - - private let label: UILabel - -} diff --git a/Example/MagazineLayoutExample/Views/ItemCreationPanelView.swift b/Example/MagazineLayoutExample/Views/ItemCreationPanelView.swift deleted file mode 100644 index 7c1a6d0..0000000 --- a/Example/MagazineLayoutExample/Views/ItemCreationPanelView.swift +++ /dev/null @@ -1,399 +0,0 @@ -// Created by bryankeller on 11/30/18. -// Copyright © 2018 Airbnb, Inc. - -// 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 MagazineLayout -import UIKit - -// MARK: - ItemCreationPanelView - -#if os(iOS) -final class ItemCreationPanelView: UIView { - - // MARK: Lifecycle - - init(dataSourceCountsProvider: DataSourceCountsProvider) { - self.dataSourceCountsProvider = dataSourceCountsProvider - - sectionIndexLabel = UILabel(frame: .zero) - sectionIndexStepper = UIStepper(frame: .zero) - itemIndexLabel = UILabel(frame: .zero) - itemIndexStepper = UIStepper(frame: .zero) - widthModeLabel = UILabel(frame: .zero) - widthModePicker = UIPickerView(frame: .zero) - heightModeLabel = UILabel(frame: .zero) - heightModePicker = UIPickerView(frame: .zero) - textFieldLabel = UILabel(frame: .zero) - textField = UITextField(frame: .zero) - colorLabel = UILabel(frame: .zero) - colorSegmentedControl = UISegmentedControl(frame: .zero) - - super.init(frame: .zero) - - let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(viewTapped)) - addGestureRecognizer(tapGestureRecognizer) - - setUpViews() - setUpConstraints() - updateAndValidateUIState() - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Internal - - var state: ItemCreationPanelViewState { - get { - let widthMode: MagazineLayoutItemWidthMode - switch widthModePicker.selectedRow(inComponent: 0) { - case 0: widthMode = .fullWidth(respectsHorizontalInsets: true) - case 1: widthMode = .fullWidth(respectsHorizontalInsets: false) - case 2: widthMode = .halfWidth - case 3: widthMode = .thirdWidth - case 4: widthMode = .fourthWidth - case 5: widthMode = .fifthWidth - case 6: widthMode = .fractionalWidth(divisor: 10) - default: widthMode = .fullWidth(respectsHorizontalInsets: true) - } - - let heightMode: MagazineLayoutItemHeightMode - switch heightModePicker.selectedRow(inComponent: 0) { - case 0: heightMode = .static(height: 50) - case 1: heightMode = .dynamic - case 2: heightMode = .dynamicAndStretchToTallestItemInRow - default: heightMode = .dynamic - } - - let color = colors[colorSegmentedControl.selectedSegmentIndex] - - return ItemCreationPanelViewState( - sectionIndex: Int(sectionIndexStepper.value), - itemIndex: Int(itemIndexStepper.value), - sizeMode: MagazineLayoutItemSizeMode(widthMode: widthMode, heightMode: heightMode), - text: textField.text ?? "", - color: color) - } - set { - sectionIndexStepper.value = Double(newValue.sectionIndex) - itemIndexStepper.value = Double(newValue.itemIndex) - - switch newValue.sizeMode.widthMode { - case .fullWidth(respectsHorizontalInsets: true): - widthModePicker.selectRow(0, inComponent: 0, animated: false) - case .fullWidth(respectsHorizontalInsets: false): - widthModePicker.selectRow(1, inComponent: 0, animated: false) - case .fractionalWidth(divisor: 2): - widthModePicker.selectRow(2, inComponent: 0, animated: false) - case .fractionalWidth(divisor: 3): - widthModePicker.selectRow(3, inComponent: 0, animated: false) - case .fractionalWidth(divisor: 4): - widthModePicker.selectRow(4, inComponent: 0, animated: false) - case .fractionalWidth(divisor: 5): - widthModePicker.selectRow(5, inComponent: 0, animated: false) - case .fractionalWidth(divisor: 10): - widthModePicker.selectRow(10, inComponent: 0, animated: false) - default: break - } - - switch newValue.sizeMode.heightMode { - case .static: - heightModePicker.selectRow(0, inComponent: 0, animated: false) - case .dynamic: - heightModePicker.selectRow(1, inComponent: 0, animated: false) - case .dynamicAndStretchToTallestItemInRow: - heightModePicker.selectRow(2, inComponent: 0, animated: false) - } - - textField.text = newValue.text - - colorSegmentedControl.selectedSegmentIndex = colors.firstIndex(of: newValue.color) ?? 0 - - updateAndValidateUIState() - } - } - - - // MARK: Private - - private let dataSourceCountsProvider: DataSourceCountsProvider - - private let sectionIndexLabel: UILabel - private let sectionIndexStepper: UIStepper - private let itemIndexLabel: UILabel - private let itemIndexStepper: UIStepper - private let widthModeLabel: UILabel - private let widthModePicker: UIPickerView - private let heightModeLabel: UILabel - private let heightModePicker: UIPickerView - private let textFieldLabel: UILabel - private let textField: UITextField - private let colorLabel: UILabel - private let colorSegmentedControl: UISegmentedControl - - private let colors = [ - Colors.red, - Colors.orange, - Colors.green, - Colors.blue, - ] - - private func setUpViews() { - addSubview(sectionIndexLabel) - sectionIndexStepper.value = 0 - sectionIndexStepper.tintColor = .gray - sectionIndexStepper.addTarget( - self, - action: #selector(updateAndValidateUIState), - for: .valueChanged) - addSubview(sectionIndexStepper) - - addSubview(itemIndexLabel) - itemIndexStepper.value = 0 - itemIndexStepper.tintColor = .gray - itemIndexStepper.addTarget( - self, - action: #selector(updateAndValidateUIState), - for: .valueChanged) - addSubview(itemIndexStepper) - - textFieldLabel.text = "Item content" - addSubview(textFieldLabel) - textField.borderStyle = .roundedRect - textField.delegate = self - textField.text = "Item" - addSubview(textField) - - colorLabel.text = "Color" - addSubview(colorLabel) - - let renderer = UIGraphicsImageRenderer(size: CGSize(width: 20, height: 20)) - let colorImages = colors.map { color in - renderer.image { context in - context.cgContext.setFillColor(color.cgColor) - - let rectangle = CGRect(x: 0, y: 0, width: 20, height: 20) - context.cgContext.addRect(rectangle) - context.cgContext.drawPath(using: .fill) - } - } - for (index, colorImage) in colorImages.enumerated() { - colorSegmentedControl.insertSegment( - with: colorImage.withRenderingMode(.alwaysOriginal), - at: index, - animated: false) - } - colorSegmentedControl.selectedSegmentIndex = 0 - colorSegmentedControl.tintColor = .gray - addSubview(colorSegmentedControl) - - widthModeLabel.text = "Width Mode" - addSubview(widthModeLabel) - widthModePicker.layer.borderWidth = 1 - widthModePicker.layer.borderColor = UIColor.lightGray.cgColor - widthModePicker.dataSource = self - widthModePicker.delegate = self - widthModePicker.selectRow(2, inComponent: 0, animated: false) - addSubview(widthModePicker) - - heightModeLabel.text = "Height Mode" - addSubview(heightModeLabel) - heightModePicker.layer.borderWidth = 1 - heightModePicker.layer.borderColor = UIColor.lightGray.cgColor - heightModePicker.dataSource = self - heightModePicker.delegate = self - heightModePicker.selectRow(1, inComponent: 0, animated: false) - addSubview(heightModePicker) - } - - private func setUpConstraints() { - subviews.forEach { view in - view.translatesAutoresizingMaskIntoConstraints = false - } - - textFieldLabel.setContentHuggingPriority(.required, for: .horizontal) - - NSLayoutConstraint.activate([ - sectionIndexLabel.leadingAnchor.constraint( - equalTo: layoutMarginsGuide.leadingAnchor, - constant: 24), - sectionIndexLabel.centerYAnchor.constraint(equalTo: sectionIndexStepper.centerYAnchor), - - sectionIndexStepper.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), - sectionIndexStepper.trailingAnchor.constraint( - equalTo: layoutMarginsGuide.trailingAnchor, - constant: -24), - - itemIndexLabel.leadingAnchor.constraint(equalTo: sectionIndexLabel.leadingAnchor), - itemIndexLabel.centerYAnchor.constraint(equalTo: itemIndexStepper.centerYAnchor), - - itemIndexStepper.trailingAnchor.constraint(equalTo: sectionIndexStepper.trailingAnchor), - itemIndexStepper.topAnchor.constraint( - equalTo: sectionIndexStepper.bottomAnchor, - constant: 12), - - textFieldLabel.leadingAnchor.constraint(equalTo: itemIndexLabel.leadingAnchor), - textFieldLabel.centerYAnchor.constraint(equalTo: textField.centerYAnchor), - - textField.leadingAnchor.constraint(equalTo: textFieldLabel.trailingAnchor, constant: 12), - textField.trailingAnchor.constraint(equalTo: itemIndexStepper.trailingAnchor), - textField.topAnchor.constraint(equalTo: itemIndexStepper.bottomAnchor, constant: 24), - - colorLabel.leadingAnchor.constraint(equalTo: textFieldLabel.leadingAnchor), - colorLabel.centerYAnchor.constraint(equalTo: colorSegmentedControl.centerYAnchor), - - colorSegmentedControl.leadingAnchor.constraint( - equalTo: colorLabel.trailingAnchor, - constant: 12), - colorSegmentedControl.trailingAnchor.constraint(equalTo: textField.trailingAnchor), - colorSegmentedControl.topAnchor.constraint(equalTo: textField.bottomAnchor, constant: 24), - - widthModeLabel.leadingAnchor.constraint(equalTo: colorLabel.leadingAnchor), - widthModeLabel.topAnchor.constraint( - equalTo: colorSegmentedControl.bottomAnchor, constant: 24), - - widthModePicker.leadingAnchor.constraint(equalTo: widthModeLabel.leadingAnchor), - widthModePicker.trailingAnchor.constraint(equalTo: itemIndexLabel.trailingAnchor), - widthModePicker.topAnchor.constraint(equalTo: widthModeLabel.bottomAnchor, constant: 8), - widthModePicker.heightAnchor.constraint(equalToConstant: 144), - - heightModeLabel.leadingAnchor.constraint(equalTo: widthModePicker.leadingAnchor), - heightModeLabel.topAnchor.constraint(equalTo: widthModePicker.bottomAnchor, constant: 24), - - heightModePicker.leadingAnchor.constraint(equalTo: heightModeLabel.leadingAnchor), - heightModePicker.trailingAnchor.constraint(equalTo: widthModePicker.trailingAnchor), - heightModePicker.topAnchor.constraint(equalTo: heightModeLabel.bottomAnchor, constant: 8), - heightModePicker.heightAnchor.constraint(equalToConstant: 144), - heightModePicker.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor) - ]) - } - - @objc - private func updateAndValidateUIState() { - sectionIndexStepper.minimumValue = 0 - sectionIndexStepper.maximumValue = Double(dataSourceCountsProvider.numberOfSections) - - let selectedSectionIndex = Int(sectionIndexStepper.value) - itemIndexStepper.minimumValue = 0 - if selectedSectionIndex < dataSourceCountsProvider.numberOfSections { - let numberOfItemsInSection = dataSourceCountsProvider.numberOfItemsInSection( - withIndex: selectedSectionIndex) - itemIndexStepper.maximumValue = Double(numberOfItemsInSection) - itemIndexStepper.isEnabled = true - } else { - itemIndexStepper.maximumValue = 0 - itemIndexStepper.isEnabled = false - } - - sectionIndexLabel.text = "Section: \(selectedSectionIndex)" - itemIndexLabel.text = "Item: \(Int(itemIndexStepper.value))" - } - - @objc - private func viewTapped() { - textField.resignFirstResponder() - } - -} - -// MARK: UIPickerViewDataSource - -extension ItemCreationPanelView: UIPickerViewDataSource { - - func numberOfComponents(in pickerView: UIPickerView) -> Int { - return 1 - } - - func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { - if pickerView == widthModePicker { - return 7 - } else if pickerView == heightModePicker { - return 3 - } else { - return 0 - } - } - -} - -// MARK: UIPickerViewDelegate - -extension ItemCreationPanelView: UIPickerViewDelegate { - - func pickerView( - _ pickerView: UIPickerView, - viewForRow row: Int, - forComponent component: Int, - reusing view: UIView?) - -> UIView - { - let label = (view as? UILabel) ?? UILabel() - label.font = UIFont.systemFont(ofSize: 14) - label.textAlignment = .center - - if pickerView == widthModePicker { - switch row { - case 0: label.text = ".fullWidth(respectsHorizontalInsets: true)" - case 1: label.text = ".fullWidth(respectsHorizontalInsets: false)" - case 2: label.text = ".halfWidth" - case 3: label.text = ".thirdWidth" - case 4: label.text = ".fouthWidth" - case 5: label.text = ".fifthWidth" - case 6: label.text = ".fractionalWidth(divisor: 10)" - default: label.text = nil - } - } else if pickerView == heightModePicker { - switch row { - case 0: label.text = ".static(height: 50)" - case 1: label.text = ".dynamic" - case 2: label.text = ".dynamicAndStretchToTallestItemInRow" - default: label.text = nil - } - } else { - label.text = nil - } - - return label - } - -} - -// MARK: UITextFieldDelegate - -extension ItemCreationPanelView: UITextFieldDelegate { - - func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) { - textField.resignFirstResponder() - } - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - textField.resignFirstResponder() - return true - } - -} - -// MARK: - ItemCreationPanelViewState - -struct ItemCreationPanelViewState { - - let sectionIndex: Int - let itemIndex: Int - let sizeMode: MagazineLayoutItemSizeMode - let text: String - let color: UIColor - -} -#endif