├── .gitattributes ├── .gitignore ├── README.md ├── YUI.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ └── contents.xcworkspacedata └── xcshareddata │ └── xcschemes │ └── YUI.xcscheme └── YUI ├── AppDelegate.swift ├── Assets.xcassets ├── AccentColor.colorset │ └── Contents.json ├── AppIcon.appiconset │ ├── AppIcon1024x1024 1.png │ ├── AppIcon1024x1024 2.png │ ├── AppIcon1024x1024.png │ └── Contents.json ├── Background.imageset │ ├── Background.png │ └── Contents.json ├── Colors │ ├── Contents.json │ └── UntitledGrey.colorset │ │ └── Contents.json ├── Contents.json ├── Instagram │ ├── Contents.json │ └── PhotoGridView.imageset │ │ ├── Contents.json │ │ └── PhotoGridView.png ├── ModalCard │ ├── Contents.json │ └── ModalCardView.imageset │ │ ├── Contents.json │ │ └── ModalCardView.png ├── Path │ ├── Clock.imageset │ │ ├── Clock.png │ │ └── Contents.json │ ├── Contents.json │ ├── PathBackground.colorset │ │ └── Contents.json │ ├── PathRed.colorset │ │ └── Contents.json │ └── PathView.imageset │ │ ├── Contents.json │ │ └── PathView.png ├── TwitterSplashScreen │ ├── Contents.json │ ├── TwitterBlue.colorset │ │ └── Contents.json │ ├── TwitterLogo.imageset │ │ ├── Contents.json │ │ └── TwitterLogo.png │ ├── TwitterScreen.imageset │ │ ├── Contents.json │ │ └── TwitterScreenshot.png │ └── TwitterSplashScreenView.imageset │ │ ├── Contents.json │ │ └── TwitterSplashScreenView.png ├── TwitterSwipeGesture │ ├── Contents.json │ ├── TwitterGray.colorset │ │ └── Contents.json │ └── TwitterSwipeGestureView.imageset │ │ ├── Contents.json │ │ └── TwitterSwipeGestureView.png └── [untitled] │ ├── CURB.imageset │ ├── Album3.png │ └── Contents.json │ ├── CarpetGolf.imageset │ ├── Album1.png │ └── Contents.json │ ├── Contents.json │ ├── HikaruUtada.imageset │ ├── Contents.json │ └── HikaruUtada.png │ ├── NoPartyForCaoDong.imageset │ ├── Contents.json │ └── SubsonicEye 2.png │ ├── Nujabes.imageset │ ├── Contents.json │ └── SubsonicEye 3.png │ ├── PorterRobinson.imageset │ ├── Contents.json │ └── SubsonicEye 5.png │ ├── Sobs.imageset │ ├── Album2.png │ └── Contents.json │ ├── SubsonicEye.imageset │ ├── Album4.png │ └── Contents.json │ ├── TwoDoorCinemaClub.imageset │ ├── Contents.json │ └── SubsonicEye 6.png │ ├── UntitledGridView.imageset │ ├── Contents.json │ └── UntitledGridView.png │ └── toe.imageset │ ├── Contents.json │ └── SubsonicEye 1.png ├── Base.lproj └── LaunchScreen.storyboard ├── Demos ├── DemoTemplate.swift ├── Instagram │ ├── Transition │ │ ├── SharedTransitionAnimationController.swift │ │ └── SharedTransitionInteractionController.swift │ ├── ViewControllers │ │ ├── PhotoDetailView.swift │ │ └── PhotoGridView.swift │ └── Views │ │ ├── PhotoCell.swift │ │ ├── PhotoDetailViewHeader.swift │ │ ├── PhotoFooter.swift │ │ ├── PhotoGridViewHeader.swift │ │ └── PhotoHeader.swift ├── ModalCard │ ├── ModalCard.swift │ └── ModalCardView.swift ├── Path │ ├── PathCell.swift │ ├── PathClockTooltip.swift │ └── PathView.swift ├── TwitterSplashScreen │ └── TwitterSplashScreenView.swift ├── TwitterSwipeGesture │ ├── TweetCell.swift │ └── TwitterSwipeGestureView.swift └── [untitled] │ ├── Transition │ └── [untitled]TransitionAnimationController.swift │ ├── ViewControllers │ ├── [untitled]DetailView.swift │ └── [untitled]GridView.swift │ └── Views │ ├── AlbumCell.swift │ ├── [untitled]DetailViewHeader.swift │ └── [untitled]GridViewHeader.swift ├── HomeView ├── HomeTransitionAnimationController.swift ├── HomeTransitioning.swift ├── HomeView.swift └── HomeViewCell.swift ├── Info.plist ├── SceneDelegate.swift ├── Transition ├── SharedTransitioning.swift └── TransitionType.swift ├── Utils ├── CGAffineTransform+Extensions.swift ├── CGRect+Extensions.swift ├── CGSize+Extensions.swift ├── ClosureLayout │ ├── LayoutAnchor.swift │ ├── LayoutClosure.swift │ ├── LayoutDimension.swift │ ├── LayoutOperators.swift │ ├── LayoutProperty.swift │ ├── LayoutProxy.swift │ ├── LayoutSizeDimension.swift │ └── UIView+Fill.swift ├── Constants.swift ├── PHAsset+Extensions.swift ├── RandomColor.swift ├── Then.swift ├── UIApplication+Extensions.swift ├── UICollectionReusableView+Extensions.swift ├── UICollectionView+Extensions.swift ├── UIScreen+Extensions.swift ├── UIView+Extensions.swift └── ViewControllerIdentifiable.swift └── Views ├── BackButton.swift └── CacheableImageView ├── CacheableImageView.swift └── ImageCache.swift /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## General 2 | .DS_Store 3 | 4 | ## User settings 5 | xcuserdata/ 6 | 7 | ## Obj-C/Swift specific 8 | *.hmap 9 | 10 | ## App packaging 11 | *.ipa 12 | *.dSYM.zip 13 | *.dSYM 14 | 15 | ## Playgrounds 16 | timeline.xctimeline 17 | playground.xcworkspace 18 | 19 | # Swift Package Manager 20 | # 21 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 22 | # Packages/ 23 | # Package.pins 24 | # Package.resolved 25 | # *.xcodeproj 26 | # 27 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 28 | # hence it is not needed unless you have added a package configuration file to your project 29 | # .swiftpm 30 | 31 | .build/ 32 | 33 | # CocoaPods 34 | # 35 | # We recommend against adding the Pods directory to your .gitignore. However 36 | # you should judge for yourself, the pros and cons are mentioned at: 37 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 38 | # 39 | # Pods/ 40 | # 41 | # Add this line if you want to avoid checking in source code from the Xcode workspace 42 | # *.xcworkspace 43 | 44 | # Carthage 45 | # 46 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 47 | # Carthage/Checkouts 48 | 49 | Carthage/Build/ 50 | 51 | # fastlane 52 | # 53 | # It is recommended to not store the screenshots in the git repo. 54 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 55 | # For more information about the recommended setup visit: 56 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 57 | 58 | fastlane/report.xml 59 | fastlane/Preview.html 60 | fastlane/screenshots/**/*.png 61 | fastlane/test_output -------------------------------------------------------------------------------- /YUI.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /YUI.xcodeproj/xcshareddata/xcschemes/YUI.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /YUI/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @main 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | func application(_ application: UIApplication, 6 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool 7 | { 8 | // Override point for customization after application launch. 9 | return true 10 | } 11 | 12 | // MARK: UISceneSession Lifecycle 13 | 14 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 15 | // Called when a new scene session is being created. 16 | // Use this method to select a configuration to create the new scene with. 17 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 18 | } 19 | 20 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 21 | // Called when the user discards a scene session. 22 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 23 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xFF", 9 | "green" : "0xFF", 10 | "red" : "0xFE" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xFF", 27 | "green" : "0xFF", 28 | "red" : "0xFE" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/AppIcon.appiconset/AppIcon1024x1024 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/AppIcon.appiconset/AppIcon1024x1024 1.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/AppIcon.appiconset/AppIcon1024x1024 2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/AppIcon.appiconset/AppIcon1024x1024 2.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/AppIcon.appiconset/AppIcon1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/AppIcon.appiconset/AppIcon1024x1024.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon1024x1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "filename" : "AppIcon1024x1024 1.png", 17 | "idiom" : "universal", 18 | "platform" : "ios", 19 | "size" : "1024x1024" 20 | }, 21 | { 22 | "appearances" : [ 23 | { 24 | "appearance" : "luminosity", 25 | "value" : "tinted" 26 | } 27 | ], 28 | "filename" : "AppIcon1024x1024 2.png", 29 | "idiom" : "universal", 30 | "platform" : "ios", 31 | "size" : "1024x1024" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/Background.imageset/Background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/Background.imageset/Background.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/Background.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Background.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/Colors/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/Colors/UntitledGrey.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x19", 9 | "green" : "0x19", 10 | "red" : "0x19" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x19", 27 | "green" : "0x19", 28 | "red" : "0x19" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/Instagram/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/Instagram/PhotoGridView.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "PhotoGridView.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/Instagram/PhotoGridView.imageset/PhotoGridView.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/Instagram/PhotoGridView.imageset/PhotoGridView.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/ModalCard/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/ModalCard/ModalCardView.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "ModalCardView.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/ModalCard/ModalCardView.imageset/ModalCardView.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/ModalCard/ModalCardView.imageset/ModalCardView.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/Path/Clock.imageset/Clock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/Path/Clock.imageset/Clock.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/Path/Clock.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Clock.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/Path/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/Path/PathBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xEE", 9 | "green" : "0xF2", 10 | "red" : "0xF8" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xEE", 27 | "green" : "0xF2", 28 | "red" : "0xF8" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/Path/PathRed.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x17", 9 | "green" : "0x2E", 10 | "red" : "0xE4" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x17", 27 | "green" : "0x2E", 28 | "red" : "0xE4" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/Path/PathView.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "PathView.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/Path/PathView.imageset/PathView.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/Path/PathView.imageset/PathView.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/TwitterSplashScreen/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/TwitterSplashScreen/TwitterBlue.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xF2", 9 | "green" : "0xA1", 10 | "red" : "0x1D" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xF2", 27 | "green" : "0xA1", 28 | "red" : "0x1D" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/TwitterSplashScreen/TwitterLogo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "TwitterLogo.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/TwitterSplashScreen/TwitterLogo.imageset/TwitterLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/TwitterSplashScreen/TwitterLogo.imageset/TwitterLogo.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/TwitterSplashScreen/TwitterScreen.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "TwitterScreenshot.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/TwitterSplashScreen/TwitterScreen.imageset/TwitterScreenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/TwitterSplashScreen/TwitterScreen.imageset/TwitterScreenshot.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/TwitterSplashScreen/TwitterSplashScreenView.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "TwitterSplashScreenView.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/TwitterSplashScreen/TwitterSplashScreenView.imageset/TwitterSplashScreenView.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/TwitterSplashScreen/TwitterSplashScreenView.imageset/TwitterSplashScreenView.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/TwitterSwipeGesture/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/TwitterSwipeGesture/TwitterGray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xD9", 9 | "green" : "0xD5", 10 | "red" : "0xCB" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xD9", 27 | "green" : "0xD5", 28 | "red" : "0xCB" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/TwitterSwipeGesture/TwitterSwipeGestureView.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "TwitterSwipeGestureView.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/TwitterSwipeGesture/TwitterSwipeGestureView.imageset/TwitterSwipeGestureView.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/TwitterSwipeGesture/TwitterSwipeGestureView.imageset/TwitterSwipeGestureView.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/CURB.imageset/Album3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/[untitled]/CURB.imageset/Album3.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/CURB.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Album3.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/CarpetGolf.imageset/Album1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/[untitled]/CarpetGolf.imageset/Album1.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/CarpetGolf.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Album1.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/HikaruUtada.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "HikaruUtada.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/HikaruUtada.imageset/HikaruUtada.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/[untitled]/HikaruUtada.imageset/HikaruUtada.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/NoPartyForCaoDong.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "SubsonicEye 2.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/NoPartyForCaoDong.imageset/SubsonicEye 2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/[untitled]/NoPartyForCaoDong.imageset/SubsonicEye 2.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/Nujabes.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "SubsonicEye 3.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/Nujabes.imageset/SubsonicEye 3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/[untitled]/Nujabes.imageset/SubsonicEye 3.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/PorterRobinson.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "SubsonicEye 5.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/PorterRobinson.imageset/SubsonicEye 5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/[untitled]/PorterRobinson.imageset/SubsonicEye 5.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/Sobs.imageset/Album2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/[untitled]/Sobs.imageset/Album2.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/Sobs.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Album2.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/SubsonicEye.imageset/Album4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/[untitled]/SubsonicEye.imageset/Album4.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/SubsonicEye.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Album4.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/TwoDoorCinemaClub.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "SubsonicEye 6.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/TwoDoorCinemaClub.imageset/SubsonicEye 6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/[untitled]/TwoDoorCinemaClub.imageset/SubsonicEye 6.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/UntitledGridView.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "UntitledGridView.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/UntitledGridView.imageset/UntitledGridView.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/[untitled]/UntitledGridView.imageset/UntitledGridView.png -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/toe.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "SubsonicEye 1.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /YUI/Assets.xcassets/[untitled]/toe.imageset/SubsonicEye 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yihui-hu/YUI/b5ae1134498f5790bd73b49dd7c697eb4fe24d76/YUI/Assets.xcassets/[untitled]/toe.imageset/SubsonicEye 1.png -------------------------------------------------------------------------------- /YUI/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /YUI/Demos/DemoTemplate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class DemoTemplate: UIViewController, ViewControllerIdentifiable { 4 | var stringIdentifier: String = "DemoTemplateView" 5 | var nameIdentifier: String = "Demo Template" 6 | 7 | override func viewDidLoad() { 8 | super.viewDidLoad() 9 | 10 | setupViews() 11 | setupBackButton() 12 | } 13 | 14 | override func viewDidAppear(_ animated: Bool) { 15 | super.viewDidAppear(animated) 16 | navigationController?.navigationBar.isHidden = true 17 | } 18 | 19 | private func setupViews() { 20 | view.layer.masksToBounds = true 21 | // Setup views here 22 | } 23 | 24 | private func setupBackButton() { 25 | let backButton = BackButton(blurStyle: .systemUltraThinMaterialDark) 26 | backButton.backNavigation = { [weak self] in 27 | self?.navigationController?.popViewController(animated: true) 28 | } 29 | 30 | backButton.then { 31 | view.addSubview($0) 32 | }.layout { 33 | $0.leading == view.leadingAnchor + 20 34 | $0.bottom == view.safeAreaLayoutGuide.bottomAnchor - 20 35 | } 36 | } 37 | } 38 | 39 | extension DemoTemplate: HomeTransitioning { 40 | var sharedView: UIView? { 41 | return UIView() 42 | } 43 | 44 | override var prefersHomeIndicatorAutoHidden: Bool { 45 | return true 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /YUI/Demos/Instagram/Transition/SharedTransitionAnimationController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// This class is an "animation controller" and conforms to 4 | /// `UIViewControllerAnimatedTransitioning` in order to implement a 5 | /// custom transition animation when pushing and popping. 6 | /// 7 | /// We return an instance of this animation controller to view controllers that conform to 8 | /// `UINavigationControllerDelegate` through the required method 9 | /// `navigationController(_:animationControllerFor:from:to: UIViewController)`. 10 | /// 11 | /// The reason why we don't have this animation controller conform to the delegate and 12 | /// implement the method above is as follows: 13 | /// 14 | /// 1. It requires shared state between view controllers: for example, the `PhotoGridView` 15 | /// needs to track the `selectedIndexPath` for the transition as it tells us about the 16 | /// `cell` and its `frame`, which it retrieves from the `collectionView`. 17 | /// 2. The transitions are specific to paired navigations, like the one between `PhotoGridView` 18 | /// and `PhotoDetailView`. We have custom logic to determine when to apply the transition 19 | /// (checking if `fromVC` and `toVC` are the types we expect) 20 | /// 3. For interactive transitions, e.g. like the one in `PhotoDetailView`, the pan gesture needs 21 | /// direct access to this animation controller and the interaction controller. 22 | /// 23 | final class SharedTransitionAnimationController: NSObject { 24 | var transition: TransitionType = .push 25 | private var config: SharedTransitionConfig = .default 26 | } 27 | 28 | extension SharedTransitionAnimationController: UIViewControllerAnimatedTransitioning { 29 | func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval 30 | { 31 | config.duration 32 | } 33 | 34 | func animateTransition(using transitionContext: UIViewControllerContextTransitioning) 35 | { 36 | // Before animation begins, prepare the view controllers for any configuration 37 | // required for the transition 38 | prepareViewControllers(from: transitionContext, for: transition) 39 | 40 | // Depending on transition type, run a different animation type 41 | switch transition { 42 | case .push: 43 | pushAnimation(with: transitionContext) 44 | case .pop: 45 | popAnimation(with: transitionContext) 46 | } 47 | } 48 | } 49 | 50 | extension SharedTransitionAnimationController { 51 | /// The push animation of our custom`SharedTransition` 52 | /// 53 | private func pushAnimation(with context: UIViewControllerContextTransitioning) { 54 | // Retrieve necessary components for the transition (namely, the shared view between 55 | // the two view controllers). 56 | // 57 | // Also adds the destination view to the containerView. 58 | guard let (fromView, fromFrame, toView, toFrame) = setup(with: context) else { 59 | context.completeTransition(false) 60 | return 61 | } 62 | 63 | // Calculate the initial transformation for the destination view, 64 | // scaling it down such that the shared view of the destination view 65 | // aspect fills the shared view of source view. 66 | // 67 | // This gives the effect that the destination view grows from the source view 68 | // (unless source view is larger than destination view for some reason, in 69 | // which case the latter shrinks from the former). 70 | let transform: CGAffineTransform = .transform( 71 | parent: toView.frame, // e.g. the detail view 72 | suchThatChild: toFrame, // e.g. the photo in the detail view 73 | aspectFills: fromFrame // e.g. the photo cell in the grid view 74 | ) 75 | toView.transform = transform 76 | 77 | // Compute and set the mask for the destination view, which will 78 | // expand throughout the animation 79 | let maskFrame = fromFrame.aspectFit(to: toFrame) 80 | let mask = UIView(frame: maskFrame).then { 81 | $0.layer.cornerCurve = .continuous 82 | $0.backgroundColor = .black 83 | } 84 | toView.mask = mask 85 | 86 | // Add placeholder view to cover up the fromView (e.g. the photo cell) to create the 87 | // illusion of the source view expanding and departing from its origin to the dest. view 88 | let placeholder = UIView().then { 89 | $0.backgroundColor = config.placeholderColor 90 | $0.frame = fromFrame 91 | } 92 | fromView.addSubview(placeholder) 93 | 94 | // Add dark backdrop to the source view 95 | let backdrop = UIView().then { 96 | $0.backgroundColor = .black 97 | $0.layer.opacity = 0 98 | $0.frame = fromView.frame 99 | } 100 | fromView.addSubview(backdrop) 101 | 102 | // Within the animation block, gradually: 103 | // 1. Revert destination view to its original dimensions and position 104 | // 2. Adjust mask's frame to unveil entire destination screen, applying a corner radius too 105 | // 3. Increase opacity of dark backdrop 106 | // 107 | // And afterwards, in the completion handler: 108 | // 1. Dispose of the mask, backdrop and placeholder view 109 | // 2. Invoke `completeTransition` on `transitionContext` to signal that transition is done 110 | UIView.animate( 111 | withDuration: 0.4, 112 | delay: 0, 113 | usingSpringWithDamping: 2, 114 | initialSpringVelocity: 0.4, 115 | options: []) 116 | { [config] in 117 | toView.transform = .identity 118 | mask.frame = toView.frame 119 | mask.layer.cornerRadius = config.maskCornerRadius 120 | backdrop.layer.opacity = config.overlayOpacity 121 | } completion: { _ in 122 | toView.mask = nil 123 | backdrop.removeFromSuperview() 124 | placeholder.removeFromSuperview() 125 | context.completeTransition(true) 126 | } 127 | } 128 | 129 | /// The pop animation of our custom`SharedTransition` 130 | /// 131 | private func popAnimation(with context: UIViewControllerContextTransitioning) { 132 | // Retrieve necessary components for the transition (namely, the shared view between 133 | // the two view controllers). 134 | // 135 | // Also adds the destination view to the containerView, but this time positioning it under 136 | // the source view. 137 | guard let (fromView, fromFrame, toView, toFrame) = setup(with: context) else { 138 | context.completeTransition(false) 139 | return 140 | } 141 | 142 | let transform: CGAffineTransform = .transform( 143 | parent: fromView.frame, // e.g. the detail view 144 | suchThatChild: fromFrame, // e.g. the photo in the detail view 145 | aspectFills: toFrame // e.g. the photo cell in the grid view 146 | ) 147 | 148 | // Compute and set the mask, which starts off with the same 149 | // frame size as the source view, but gradually shrinks 150 | let mask = UIView(frame: fromView.frame).then { 151 | $0.layer.cornerCurve = .continuous 152 | $0.backgroundColor = .black 153 | $0.layer.cornerRadius = config.maskCornerRadius 154 | } 155 | fromView.mask = mask 156 | 157 | // Add dark backdrop to destination view 158 | let backdrop = UIView().then { 159 | $0.backgroundColor = .black 160 | $0.layer.opacity = config.overlayOpacity 161 | $0.frame = toView.frame 162 | } 163 | toView.addSubview(backdrop) 164 | 165 | // Add placeholder view to cover up the toView (e.g. the photo cell) to create the 166 | // illusion of the source view shrinking and returning to its origin in the dest. view 167 | let placeholder = UIView().then { 168 | $0.backgroundColor = config.placeholderColor 169 | $0.frame = toFrame 170 | } 171 | toView.addSubview(placeholder) 172 | 173 | // Determine the final frame for the mask to guide its shrinking during the transition 174 | let maskFrame = toFrame.aspectFit(to: fromFrame) 175 | 176 | // Within the animation block, gradually: 177 | // 1. Apply the transform to the source view 178 | // 2. Resize mask of the source view according to the final frame, removing corner radius 179 | // 3. Remove dark backdrop 180 | // 181 | // And afterwards, in the completion handler: 182 | // 1. Dispose of the mask, backdrop and placeholder view 183 | // 2. In the event that the transition is interactive, check if it was cancelled 184 | // 3. Invoke `completeTransition` on `transitionContext` to signal that transition is done 185 | UIView.animate( 186 | withDuration: 0.4, 187 | delay: 0, 188 | usingSpringWithDamping: 2, 189 | initialSpringVelocity: 0.4, 190 | options: []) 191 | { 192 | fromView.transform = transform 193 | mask.frame = maskFrame 194 | mask.layer.cornerRadius = 0 195 | backdrop.layer.opacity = 0 196 | } completion: { _ in 197 | fromView.mask = nil 198 | backdrop.removeFromSuperview() 199 | placeholder.removeFromSuperview() 200 | let isCancelled = context.transitionWasCancelled 201 | context.completeTransition(!isCancelled) 202 | } 203 | } 204 | 205 | /// Before the animation begins, prepare the view controllers for the transition if necessary – unrelated to 206 | /// the actual animation of the transition. 207 | /// 208 | private func prepareViewControllers(from context: UIViewControllerContextTransitioning, 209 | for transition: TransitionType) 210 | { 211 | // Get the source and destination view controllers from the context object 212 | let fromVC = context.viewController(forKey: .from) as? SharedTransitioning 213 | let toVC = context.viewController(forKey: .to) as? SharedTransitioning 214 | 215 | // If a configuration exists for the source view controller, use it 216 | if let customConfig = fromVC?.config { config = customConfig } 217 | 218 | // Prepare the view controllers for transition if necessary 219 | // e.g. PhotoGridView ensures the selected cell is visible before pop 220 | fromVC?.prepare(for: transition) 221 | toVC?.prepare(for: transition) 222 | } 223 | 224 | /// This function is used to handle the initial configuration needed for both the push and pop transitions, 225 | /// using the `UIViewControllerContextTransitioning` object to obtain info about both 226 | /// the source and destination view controllers. 227 | /// 228 | private func setup(with context: UIViewControllerContextTransitioning) -> (UIView, CGRect, UIView, CGRect)? 229 | { 230 | // Get the source and destination views 231 | guard let toView = context.view(forKey: .to), 232 | let fromView = context.view(forKey: .from) else 233 | { 234 | return nil 235 | } 236 | 237 | // Add views to the empty container view in the correct order 238 | // For push: add destination view on top 239 | // For pop: add destination view below source view 240 | if transition == .push { 241 | context.containerView.addSubview(toView) 242 | } else { 243 | context.containerView.insertSubview(toView, belowSubview: fromView) 244 | } 245 | 246 | // Ensure the frames that will be used for the shared transition exist 247 | // in both view controllers 248 | guard let toFrame = context.sharedFrame(forKey: .to), 249 | let fromFrame = context.sharedFrame(forKey: .from) else 250 | { 251 | return nil 252 | } 253 | 254 | // Return necessary components for the transition 255 | return (fromView, fromFrame, toView, toFrame) 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /YUI/Demos/Instagram/Transition/SharedTransitionInteractionController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// This class is used to implement the interactive pop transition that can be activated by a gesture 4 | /// and leverages the existing, non-interactive pop transition in order to "interpolate" between the 5 | /// two states: the interactive portion driven by gesture, and non-interactive portion after user ends gesture. 6 | /// 7 | class SharedTransitionInteractionController: NSObject { 8 | 9 | /// An internal struct designed to store the frames, transformations and references of 10 | /// the participants of the interactive transition. 11 | /// 12 | struct Context { 13 | var transitionContext: UIViewControllerContextTransitioning 14 | var fromFrame: CGRect 15 | var toFrame: CGRect 16 | var fromView: UIView 17 | var toView: UIView 18 | var mask: UIView 19 | var transform: CGAffineTransform 20 | var backdrop: UIView 21 | var placeholder: UIView 22 | } 23 | 24 | /// We store the context as a property of this class, since the system invokes 25 | /// `startInteractiveTransition(_ transitionContext:)` only once 26 | /// at the beginning of the transition. To successfully execute the interactive transition, 27 | /// we need access to these components throughout the process. 28 | /// 29 | public var context: Context? 30 | 31 | private var config: SharedTransitionConfig = .interactive 32 | private var alreadyFinished = false 33 | private var alreadyCancelled = false 34 | } 35 | 36 | extension SharedTransitionInteractionController: UIViewControllerInteractiveTransitioning { 37 | var wantsInteractiveStart: Bool { false } 38 | 39 | // Tells the navigation controller to begin the interactive transition 40 | func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) { 41 | prepareViewController(from: transitionContext) 42 | 43 | guard let (fromView, fromFrame, toView, toFrame) = setup(with: transitionContext) else { 44 | transitionContext.completeTransition(false) 45 | return 46 | } 47 | 48 | let transform: CGAffineTransform = .transform( 49 | parent: fromView.frame, 50 | suchThatChild: fromFrame, 51 | aspectFills: toFrame 52 | ) 53 | 54 | let mask = UIView(frame: fromView.frame).then { 55 | $0.layer.cornerCurve = .continuous 56 | $0.backgroundColor = .black 57 | $0.layer.cornerRadius = config.maskCornerRadius 58 | } 59 | fromView.mask = mask 60 | 61 | let placeholder = UIView().then { 62 | $0.frame = toFrame 63 | $0.backgroundColor = config.placeholderColor 64 | } 65 | toView.addSubview(placeholder) 66 | 67 | let backdrop = UIView().then { 68 | $0.backgroundColor = .black 69 | $0.layer.opacity = config.overlayOpacity 70 | $0.frame = toView.frame 71 | } 72 | toView.addSubview(backdrop) 73 | 74 | // Populate context with the calculated values and 75 | // initial state, stored for later use 76 | context = Context( 77 | transitionContext: transitionContext, 78 | fromFrame: fromFrame, 79 | toFrame: toFrame, 80 | fromView: fromView, 81 | toView: toView, 82 | mask: mask, 83 | transform: transform, 84 | backdrop: backdrop, 85 | placeholder: placeholder 86 | ) 87 | 88 | // Prevent gesture conflicts when view is already animating 89 | if alreadyFinished { finish() } 90 | if alreadyCancelled { cancel() } 91 | } 92 | } 93 | 94 | /// This extension overrides the default methods of an interactive transition. 95 | /// 96 | /// - `update(_ percentComplete:)`: Updates the progress of our animation based on a percentage value ranging from 0 to 1. 97 | /// - `cancel()`: Reverts the animation, transitioning our views back to their initial state and consequently aborting the transition. 98 | /// - `finish()`: Concludes the animation by playing it through to the end from its current progress point. 99 | /// 100 | extension SharedTransitionInteractionController { 101 | func update(_ recognizer: UIPanGestureRecognizer) { 102 | // Ensure context struct exists, since we need its components 103 | // to drive our interactive transition 104 | guard let context else { return } 105 | 106 | // Determine progress of the transition via the horizontal translation 107 | // of the gesture 108 | let window = UIApplication.keyWindow! 109 | let translation = recognizer.translation(in: window) 110 | 111 | let progress = abs(translation.x / window.frame.width) 112 | 113 | // Inform UIKit of our transition’s progress, using the transitionContext 114 | // reference previously saved in our context. 115 | context.transitionContext.updateInteractiveTransition(progress) 116 | 117 | // Determine scale of the fromView based on the horizontal translation 118 | var scaleFactor = 1 - progress * (1 - config.interactionScaleFactor) 119 | scaleFactor = min(max(scaleFactor, config.interactionScaleFactor), 1) 120 | 121 | // Combine both translation and scale transformations 122 | context.fromView.transform = .init(scaleX: scaleFactor, 123 | y: scaleFactor) 124 | .translatedBy(x: translation.x, 125 | y: translation.y) 126 | } 127 | 128 | func cancel() { 129 | // Ensure context struct exists, since we need its components 130 | // to drive our interactive transition, and set alreadyCancelled 131 | // flag to true to prevent multiple swipe conflicts 132 | guard let context else { 133 | alreadyCancelled = true 134 | return 135 | } 136 | 137 | // Let UIKit know that the interactive portion of this transition is cancelled 138 | // so that the usual non-interactive portion can take over from where it's left off 139 | context.transitionContext.cancelInteractiveTransition() 140 | 141 | // Revert changes made during the transition 142 | let maskRadius = config.maskCornerRadius 143 | let overlayOpacity = config.overlayOpacity 144 | UIView.animate(duration: config.duration, curve: config.curve) { 145 | context.fromView.transform = .identity 146 | context.mask.frame = context.fromView.frame 147 | context.mask.layer.cornerRadius = maskRadius 148 | context.backdrop.layer.opacity = overlayOpacity 149 | } completion: { 150 | // Cleanup views 151 | context.backdrop.removeFromSuperview() 152 | context.placeholder.removeFromSuperview() 153 | context.toView.removeFromSuperview() 154 | 155 | // Signal to UIKit that transition is cancelled 156 | context.transitionContext.completeTransition(false) 157 | } 158 | } 159 | 160 | func finish() { 161 | // Ensure context struct exists, since we need its components 162 | // to drive our interactive transition, and set alreadyFinished 163 | // flag to true to prevent multiple swipe conflicts 164 | guard let context else { 165 | alreadyFinished = true 166 | return 167 | } 168 | 169 | // Let UIKit know that the interactive portion of this transition is finished 170 | // so that the usual non-interactive portion can take over from where it's left off 171 | context.transitionContext.finishInteractiveTransition() 172 | 173 | // Update views 174 | let maskFrame = context.toFrame.aspectFit(to: context.fromFrame) 175 | UIView.animate(duration: config.duration, curve: config.curve) { 176 | context.fromView.transform = context.transform 177 | context.mask.frame = maskFrame 178 | context.mask.layer.cornerRadius = 0 179 | context.backdrop.layer.opacity = 0 180 | } completion: { 181 | // Cleanup views 182 | context.backdrop.removeFromSuperview() 183 | context.placeholder.removeFromSuperview() 184 | 185 | // Signal to UIKit that transition is complete 186 | context.transitionContext.completeTransition(true) 187 | } 188 | } 189 | } 190 | 191 | extension SharedTransitionInteractionController { 192 | private func prepareViewController(from context: UIViewControllerContextTransitioning) { 193 | let toVC = context.viewController(forKey: .to) as? SharedTransitioning 194 | toVC?.prepare(for: .pop) 195 | } 196 | 197 | /// Setup the transition by supplying the required components to the interaction controller. 198 | /// Only handles pop transitions (hence adding `toView` beneath `fromView`). 199 | /// 200 | private func setup(with context: UIViewControllerContextTransitioning) -> (UIView, CGRect, UIView, CGRect)? { 201 | guard let toView = context.view(forKey: .to), 202 | let fromView = context.view(forKey: .from) else { 203 | return nil 204 | } 205 | 206 | context.containerView.insertSubview(toView, belowSubview: fromView) 207 | 208 | guard let toFrame = context.sharedFrame(forKey: .to), 209 | let fromFrame = context.sharedFrame(forKey: .from) else 210 | { 211 | return nil 212 | } 213 | 214 | return (fromView, fromFrame, toView, toFrame) 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /YUI/Demos/Instagram/ViewControllers/PhotoDetailView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Photos 3 | 4 | final class PhotoDetailView: UIViewController { 5 | private var image: UIImage 6 | private var asset: PHAsset 7 | 8 | private let scrollView = UIScrollView() 9 | private let contentView = UIView() 10 | private let header = PhotoDetailViewHeader() 11 | private let imageView = UIImageView() 12 | private lazy var imageFooter: PhotoFooter = { 13 | let dateFormatter = DateFormatter() 14 | dateFormatter.dateStyle = .medium 15 | dateFormatter.timeStyle = .none 16 | let dateString = asset.creationDate.map { dateFormatter.string(from: $0) } ?? "" 17 | return PhotoFooter(date: dateString) 18 | }() 19 | private lazy var panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) 20 | private let transitionAnimator = SharedTransitionAnimationController() 21 | 22 | // We supply an interaction controller object here that conforms to `UIViewControllerInteractiveTransitioning` 23 | // in order to support interactive transitions. The reason for making it optional is to ensure 24 | // that our custom, non-interactive transition is preserved when the user taps the back button. 25 | // We instantiate this solely when a gesture is detected, and remove it once the gesture ends. 26 | private var interactionController: SharedTransitionInteractionController? 27 | 28 | private let imageManager = PHCachingImageManager() 29 | 30 | init(image: UIImage, asset: PHAsset) { 31 | self.image = image 32 | self.asset = asset 33 | 34 | super.init(nibName: nil, bundle: nil) 35 | 36 | self.imageView.contentMode = .scaleAspectFit 37 | self.imageView.backgroundColor = .white 38 | self.imageView.accessibilityIgnoresInvertColors = true 39 | self.view.backgroundColor = .white 40 | 41 | let imageRequestOptions = PHImageRequestOptions() 42 | imageRequestOptions.resizeMode = .none 43 | imageRequestOptions.isNetworkAccessAllowed = true 44 | imageRequestOptions.deliveryMode = .highQualityFormat 45 | self.imageManager.requestImage( 46 | for: asset, 47 | targetSize: self.view.bounds.size.pixelSize, 48 | contentMode: .aspectFit, 49 | options: imageRequestOptions 50 | ) { (image, info) in 51 | self.imageView.image = image 52 | } 53 | } 54 | 55 | required init?(coder: NSCoder) { 56 | fatalError("init(coder:) has not been implemented") 57 | } 58 | 59 | override func viewDidLoad() { 60 | super.viewDidLoad() 61 | navigationController?.navigationBar.isHidden = true 62 | setupView() 63 | } 64 | 65 | override func viewDidAppear(_ animated: Bool) { 66 | super.viewDidAppear(animated) 67 | navigationController?.delegate = self 68 | } 69 | } 70 | 71 | extension PhotoDetailView { 72 | private func setupView() { 73 | view.backgroundColor = .white 74 | 75 | // Add pan gesture recognizer that will activate the interactive pop transition 76 | view.addGestureRecognizer(panGestureRecognizer) 77 | panGestureRecognizer.delegate = self 78 | 79 | setupHeader() 80 | setupScrollView() 81 | setupImageView() 82 | setupImageFooter() 83 | } 84 | 85 | private func setupHeader() { 86 | header.then { 87 | view.addSubview($0) 88 | $0.backNavigation = { [weak self] in 89 | self?.navigationController?.popViewController(animated: true) 90 | } 91 | }.layout { 92 | $0.top == view.safeAreaLayoutGuide.topAnchor + 12 93 | $0.leading == view.leadingAnchor 94 | $0.trailing == view.trailingAnchor 95 | } 96 | } 97 | 98 | private func setupScrollView() { 99 | scrollView.then { 100 | $0.alwaysBounceVertical = true 101 | view.addSubview($0) 102 | }.layout { 103 | $0.top == header.bottomAnchor 104 | $0.leading == view.leadingAnchor 105 | $0.trailing == view.trailingAnchor 106 | $0.bottom == view.bottomAnchor 107 | } 108 | 109 | contentView.then { 110 | scrollView.addSubview($0) 111 | }.layout { 112 | $0.top == scrollView.contentLayoutGuide.topAnchor 113 | $0.leading == scrollView.contentLayoutGuide.leadingAnchor 114 | $0.trailing == scrollView.contentLayoutGuide.trailingAnchor 115 | $0.bottom == scrollView.contentLayoutGuide.bottomAnchor 116 | $0.width == scrollView.frameLayoutGuide.widthAnchor 117 | } 118 | } 119 | 120 | private func setupImageView() { 121 | imageView.then { 122 | contentView.addSubview($0) 123 | $0.contentMode = .scaleAspectFill 124 | $0.layer.masksToBounds = true 125 | $0.image = image 126 | }.layout { 127 | $0.leading == contentView.leadingAnchor 128 | $0.trailing == contentView.trailingAnchor 129 | $0.top == contentView.topAnchor 130 | } 131 | 132 | let imageViewSize = imageView.image!.size 133 | let ratio = imageViewSize.height / imageViewSize.width 134 | 135 | imageView.heightAnchor.constraint( 136 | equalTo: imageView.widthAnchor, 137 | multiplier: ratio 138 | ).isActive = true 139 | } 140 | 141 | private func setupImageFooter() { 142 | imageFooter.then { 143 | contentView.addSubview($0) 144 | }.layout { 145 | $0.top == imageView.bottomAnchor + 10 146 | $0.leading == contentView.leadingAnchor 147 | $0.trailing == contentView.trailingAnchor 148 | $0.bottom == contentView.bottomAnchor - 10 149 | } 150 | } 151 | } 152 | 153 | extension PhotoDetailView: UIGestureRecognizerDelegate { 154 | func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, 155 | shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool 156 | { 157 | return true 158 | } 159 | } 160 | 161 | extension PhotoDetailView: UINavigationControllerDelegate { 162 | func navigationController(_ navigationController: UINavigationController, 163 | animationControllerFor operation: UINavigationController.Operation, 164 | from fromVC: UIViewController, 165 | to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? 166 | { 167 | // Use default animation if not going to PhotoGridView 168 | guard fromVC is Self, toVC is PhotoGridView else { return nil } 169 | 170 | transitionAnimator.transition = .pop 171 | return transitionAnimator 172 | } 173 | 174 | // Since we allow interactive transitions from this view controller, we supply our 175 | // interaction controller through this method 176 | func navigationController( 177 | _ navigationController: UINavigationController, 178 | interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? 179 | { 180 | interactionController 181 | } 182 | } 183 | 184 | extension PhotoDetailView { 185 | @objc func handlePan(_ recognizer: UIPanGestureRecognizer) { 186 | // !!!: scrollView handling 187 | // If scrollView is set to view.bottomAnchor, 188 | // when it's scrolled to the bottom and this gesture is activated, 189 | // the contentOffset jumps up for some reason. To counter this, 190 | // we save the contentOffset.y before the gesture is activated and 191 | // set it at the end. 192 | let contentOffsetY = scrollView.contentOffset.y 193 | 194 | let window = UIApplication.keyWindow! 195 | 196 | switch recognizer.state { 197 | case .began: 198 | // Check if we should start a horizontal or vertical transition 199 | // Allow horizontal from anywhere, but vertical only when at top 200 | let velocity = recognizer.velocity(in: window) 201 | let isHorizontalGesture = velocity.x > abs(velocity.y) 202 | let isAtTop = scrollView.contentOffset.y <= 0 203 | 204 | guard isHorizontalGesture || (isAtTop && velocity.y > 0) else { return } 205 | 206 | // Create interaction controller and initiate the pop navigation on the navigation controller. 207 | // 208 | // !!!: NOTE ON SUPPORTING INTERACTIVE TRANSITIONS 209 | // Prior to initiating the pop transition, the navigation controller will check for an interaction 210 | // controller (using the navigationController(navigationController:interactionControllerFor:) 211 | // method we implemented above). If it finds one, it will delegate the responsibility of driving 212 | // the transition's progress to our percent-driven interaction controller. 213 | // 214 | interactionController = SharedTransitionInteractionController() 215 | navigationController?.popViewController(animated: true) 216 | 217 | scrollView.isScrollEnabled = false 218 | 219 | case .changed: 220 | interactionController?.update(recognizer) 221 | 222 | case .ended: 223 | let horizontalVelocity = recognizer.velocity(in: window).x 224 | let translation = recognizer.translation(in: window) 225 | let horizontalProgress = abs(translation.x / window.frame.width) 226 | let verticalProgress = abs(translation.y / window.frame.height) 227 | 228 | if horizontalVelocity > 900 || 229 | horizontalProgress > 0.1 || 230 | verticalProgress > 0.1 && 231 | !(horizontalVelocity <= 0) 232 | { 233 | interactionController?.finish() 234 | } else { 235 | interactionController?.cancel() 236 | } 237 | 238 | interactionController = nil 239 | 240 | scrollView.isScrollEnabled = true 241 | 242 | default: 243 | interactionController?.cancel() 244 | interactionController = nil 245 | 246 | scrollView.isScrollEnabled = true 247 | } 248 | 249 | scrollView.contentOffset.y = contentOffsetY 250 | } 251 | } 252 | 253 | extension PhotoDetailView: SharedTransitioning { 254 | /// This provides the frame of the imageView in window coordinates. 255 | /// Used by the transition animator to animate between grid and detail views. 256 | /// 257 | var sharedFrame: CGRect { 258 | imageView.frameInWindow ?? .zero 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /YUI/Demos/Instagram/Views/PhotoCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Photos 3 | 4 | class PhotoCell: UICollectionViewCell { 5 | private let imageView = UIImageView() 6 | var requestID: PHImageRequestID? 7 | 8 | override init(frame: CGRect) { 9 | super.init(frame: frame) 10 | setupImageView() 11 | } 12 | 13 | private func setupImageView() { 14 | imageView.do { 15 | $0.contentMode = .scaleAspectFill 16 | $0.layer.masksToBounds = true 17 | contentView.addSubview($0) 18 | contentView.fillWith($0) 19 | } 20 | } 21 | 22 | required init?(coder: NSCoder) { 23 | fatalError("init(coder:) has not been implemented") 24 | } 25 | 26 | func setupWithUIImage(with img: UIImage) { 27 | imageView.image = img 28 | } 29 | 30 | override func prepareForReuse() { 31 | super.prepareForReuse() 32 | requestID = nil 33 | imageView.image = nil 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /YUI/Demos/Instagram/Views/PhotoDetailViewHeader.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class PhotoDetailViewHeader: UIView { 4 | private let separator = UIView() 5 | private let backButton = UIButton(configuration: .plain()) 6 | var backNavigation: (() -> Void)? 7 | 8 | private var backAction: UIAction { 9 | UIAction(handler: { [weak self] _ in self?.backNavigation?() }) 10 | } 11 | 12 | override init(frame: CGRect) { 13 | super.init(frame: .zero) 14 | setupView() 15 | } 16 | 17 | override var intrinsicContentSize: CGSize { 18 | CGSize(width: UIView.noIntrinsicMetric, height: 48) 19 | } 20 | 21 | required init?(coder: NSCoder) { 22 | fatalError("init(coder:) has not been implemented") 23 | } 24 | } 25 | 26 | extension PhotoDetailViewHeader { 27 | private func setupView() { 28 | setupSeparator() 29 | setupBackButton() 30 | } 31 | 32 | private func setupSeparator() { 33 | separator.then { 34 | addSubview($0) 35 | $0.backgroundColor = .black.withAlphaComponent(0.3) 36 | }.layout { 37 | $0.leading == leadingAnchor 38 | $0.trailing == trailingAnchor 39 | $0.bottom == bottomAnchor 40 | $0.height == 0.5 41 | } 42 | } 43 | 44 | private func setupBackButton() { 45 | backButton.then { 46 | addSubview($0) 47 | let configuration = UIImage.SymbolConfiguration(weight: .bold) 48 | let image = UIImage(systemName: "chevron.left", withConfiguration: configuration) 49 | $0.setImage(image, for: .normal) 50 | $0.tintColor = .black 51 | $0.addAction(backAction, for: .touchUpInside) 52 | }.layout { 53 | $0.leading == leadingAnchor + 16 54 | $0.top == topAnchor + 4 55 | $0.size == CGSize(width: 24, height: 24) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /YUI/Demos/Instagram/Views/PhotoFooter.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class PhotoFooter: UIView { 4 | private let stackView = UIStackView() 5 | private let heartIconView = UIImageView() 6 | private let messageIconView = UIImageView() 7 | private let sendIconView = UIImageView() 8 | private let bookMarkIconView = UIImageView() 9 | private let dateLabel = UILabel() 10 | 11 | init(date: String) { 12 | super.init(frame: .zero) 13 | dateLabel.text = date 14 | setupView() 15 | } 16 | 17 | override init(frame: CGRect) { 18 | super.init(frame: .zero) 19 | setupView() 20 | } 21 | 22 | required init?(coder: NSCoder) { 23 | fatalError("init(coder:) has not been implemented") 24 | } 25 | } 26 | 27 | extension PhotoFooter { 28 | private func setupView() { 29 | setupStackView() 30 | addIcon("heart", imageView: heartIconView) 31 | addIcon("message", imageView: messageIconView) 32 | addIcon("paperplane", imageView: sendIconView) 33 | setupBookMarkIconView() 34 | setupDateLabel() 35 | } 36 | 37 | private func setupStackView() { 38 | stackView.then { 39 | addSubview($0) 40 | $0.axis = .horizontal 41 | $0.alignment = .center 42 | }.layout { 43 | $0.leading == leadingAnchor + 8 44 | $0.top == topAnchor 45 | } 46 | } 47 | 48 | private func addIcon(_ iconName: String, imageView: UIImageView) { 49 | imageView.then { 50 | stackView.addArrangedSubview($0) 51 | stackView.setCustomSpacing(16, after: $0) 52 | let configuration = UIImage.SymbolConfiguration(weight: .semibold) 53 | $0.image = UIImage(systemName: iconName, 54 | withConfiguration: configuration) 55 | $0.contentMode = .scaleAspectFit 56 | $0.tintColor = .black 57 | }.layout { 58 | $0.size == CGSize(width: 28, height: 28) 59 | } 60 | } 61 | 62 | private func setupBookMarkIconView() { 63 | bookMarkIconView.then { 64 | addSubview($0) 65 | let configuration = UIImage.SymbolConfiguration(weight: .semibold) 66 | $0.image = UIImage(systemName: "bookmark", 67 | withConfiguration: configuration) 68 | $0.contentMode = .scaleAspectFit 69 | $0.tintColor = .black 70 | }.layout { 71 | $0.trailing == trailingAnchor - 8 72 | $0.size == CGSize(width: 28, height: 28) 73 | $0.centerY == stackView.centerYAnchor 74 | } 75 | } 76 | 77 | private func setupDateLabel() { 78 | dateLabel.then { 79 | $0.font = .systemFont(ofSize: 14, weight: .regular) 80 | $0.textColor = .gray 81 | addSubview($0) 82 | }.layout { 83 | $0.top == stackView.bottomAnchor + 8 84 | $0.leading == leadingAnchor + 12 85 | $0.bottom == bottomAnchor 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /YUI/Demos/Instagram/Views/PhotoGridViewHeader.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class PhotoGridViewHeader: UIView { 4 | private let stackView = UIStackView() 5 | private let titleLabel = UILabel() 6 | 7 | init(title: String) { 8 | super.init(frame: .zero) 9 | setupView() 10 | 11 | let attributedString = NSAttributedString( 12 | string: title, 13 | attributes: [ 14 | NSAttributedString.Key.kern: -1.0, 15 | NSAttributedString.Key.font: UIFont.systemFont(ofSize: 28, weight: .bold), 16 | NSAttributedString.Key.foregroundColor: UIColor.black 17 | ] 18 | ) 19 | titleLabel.attributedText = attributedString 20 | } 21 | 22 | override init(frame: CGRect) { 23 | super.init(frame: .zero) 24 | setupView() 25 | } 26 | 27 | required init?(coder: NSCoder) { 28 | fatalError("init(coder:) has not been implemented") 29 | } 30 | } 31 | 32 | extension PhotoGridViewHeader { 33 | private func setupView() { 34 | setupStackView() 35 | setupTitleLabel() 36 | } 37 | 38 | private func setupStackView() { 39 | stackView.do { 40 | addSubview($0) 41 | fillWith($0, insets: .init(top: 0, left: 16, bottom: 12, right: 12)) 42 | $0.axis = .horizontal 43 | $0.alignment = .center 44 | } 45 | } 46 | 47 | private func setupTitleLabel() { 48 | titleLabel.do { 49 | $0.setContentHuggingPriority(.required, for: .horizontal) 50 | stackView.addArrangedSubview($0) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /YUI/Demos/Instagram/Views/PhotoHeader.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class PhotoHeader: UIView { 4 | private let stackView = UIStackView() 5 | private let filenameLabel = UILabel() 6 | private let moreIconView = UIImageView() 7 | 8 | var filename: String = "" { 9 | didSet { 10 | filenameLabel.text = filename 11 | } 12 | } 13 | 14 | init(filename: String = "") { 15 | super.init(frame: .zero) 16 | self.filename = filename 17 | setupView() 18 | filenameLabel.text = filename 19 | } 20 | 21 | override init(frame: CGRect) { 22 | super.init(frame: .zero) 23 | setupView() 24 | } 25 | 26 | required init?(coder: NSCoder) { 27 | fatalError("init(coder:) has not been implemented") 28 | } 29 | } 30 | 31 | extension PhotoHeader { 32 | private func setupView() { 33 | setupStackView() 34 | setupFilenameLabel() 35 | setupMoreIconView() 36 | } 37 | 38 | private func setupStackView() { 39 | stackView.do { 40 | addSubview($0) 41 | fillWith($0, insets: .init(top: 0, left: 8, bottom: 0, right: 16)) 42 | $0.axis = .horizontal 43 | $0.alignment = .center 44 | } 45 | } 46 | 47 | private func setupFilenameLabel() { 48 | filenameLabel.do { 49 | stackView.addArrangedSubview($0) 50 | $0.font = .systemFont(ofSize: 16, weight: .semibold) 51 | $0.textColor = .black 52 | } 53 | } 54 | 55 | private func setupMoreIconView() { 56 | moreIconView.then { 57 | $0.contentMode = .scaleAspectFit 58 | $0.image = UIImage(systemName: "ellipsis") 59 | $0.tintColor = .black 60 | stackView.addArrangedSubview($0) 61 | }.layout { 62 | $0.size == CGSize(width: 22, height: 22) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /YUI/Demos/ModalCard/ModalCard.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | enum CardState { 4 | case peek 5 | case half 6 | case full 7 | 8 | var height: CGFloat { 9 | switch self { 10 | case .peek: return GlobalConstants.screenH * 0.5 11 | case .half: return GlobalConstants.screenH * 0.5 12 | case .full: return GlobalConstants.screenH * 0.8 13 | } 14 | } 15 | 16 | var bottomOffset: CGFloat { 17 | switch self { 18 | case .peek: 19 | return self.height - 60 20 | case .half, .full: 21 | return -12 22 | } 23 | } 24 | } 25 | 26 | final class ModalCard: UIView { 27 | private var currentState: CardState = .peek 28 | private var initialCenter: CGPoint = .zero 29 | private var originalHeight: CGFloat = 0 30 | 31 | private var mainHeightConstraint: NSLayoutConstraint? 32 | private var heightConstraint: NSLayoutConstraint? 33 | private var bottomConstraint: NSLayoutConstraint? 34 | 35 | override init(frame: CGRect) { 36 | super.init(frame: frame) 37 | setupView() 38 | setupGesture() 39 | } 40 | 41 | required init?(coder: NSCoder) { 42 | fatalError("init(coder:) has not been implemented") 43 | } 44 | 45 | private let contentView: UIView = { 46 | let view = UIView() 47 | view.backgroundColor = .blue.withAlphaComponent(0.2) 48 | view.layer.cornerRadius = 40 49 | view.layer.cornerCurve = .continuous 50 | view.translatesAutoresizingMaskIntoConstraints = false 51 | return view 52 | }() 53 | 54 | private func setupView() { 55 | translatesAutoresizingMaskIntoConstraints = false 56 | 57 | addSubview(contentView) 58 | NSLayoutConstraint.activate([ 59 | contentView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), 60 | contentView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), 61 | ]) 62 | 63 | // Content view constraints 64 | mainHeightConstraint = heightAnchor.constraint(equalToConstant: 0) 65 | heightConstraint = contentView.heightAnchor.constraint(equalToConstant: 0) 66 | bottomConstraint = contentView.bottomAnchor.constraint(equalTo: bottomAnchor) 67 | 68 | mainHeightConstraint?.isActive = true 69 | heightConstraint?.isActive = true 70 | bottomConstraint?.isActive = true 71 | 72 | let text = UILabel() 73 | let textAS = NSAttributedString( 74 | string: "Drag me up and down!", 75 | attributes: [ 76 | NSAttributedString.Key.kern: -0.8, 77 | NSAttributedString.Key.font: UIFont.systemFont(ofSize: 24, weight: .semibold), 78 | NSAttributedString.Key.foregroundColor: UIColor.blue.withAlphaComponent(0.2) 79 | ] 80 | ) 81 | text.attributedText = textAS 82 | text.then { 83 | addSubview($0) 84 | }.layout { 85 | $0.centerX == contentView.centerXAnchor 86 | $0.centerY == contentView.centerYAnchor 87 | } 88 | } 89 | 90 | private func setupGesture() { 91 | let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) 92 | contentView.addGestureRecognizer(panGesture) 93 | } 94 | 95 | func updateLayout(for state: CardState, animated: Bool = true) { 96 | let newHeight = state.height 97 | 98 | let animations = { 99 | self.mainHeightConstraint?.constant = newHeight 100 | self.heightConstraint?.constant = newHeight 101 | 102 | switch state { 103 | case .peek: 104 | self.bottomConstraint?.constant = newHeight - 60 105 | case .half, .full: 106 | self.bottomConstraint?.constant = -12 107 | } 108 | 109 | self.superview?.layoutIfNeeded() 110 | } 111 | 112 | if animated { 113 | UIView.animate(withDuration: 0.4, 114 | delay: 0, 115 | usingSpringWithDamping: 1.0, 116 | initialSpringVelocity: 0.2, 117 | options: [.curveEaseInOut, .allowUserInteraction], 118 | animations: animations) 119 | } else { 120 | animations() 121 | } 122 | 123 | currentState = state 124 | } 125 | 126 | @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { 127 | let translation = gesture.translation(in: self) 128 | let velocity = gesture.velocity(in: self) 129 | 130 | let dragAmount = translation.y 131 | let dragVelocity = velocity.y 132 | let dragPercentage = abs(dragAmount) / 400 133 | 134 | switch gesture.state { 135 | case .began: 136 | initialCenter = contentView.center 137 | originalHeight = contentView.bounds.height 138 | 139 | case .changed: 140 | switch currentState { 141 | case .peek: 142 | if dragAmount < 0 { 143 | let currentOffset: CGFloat = CardState.peek.bottomOffset 144 | let targetOffset: CGFloat = CardState.half.bottomOffset 145 | let offsetDifference = targetOffset - currentOffset 146 | let newOffset = currentOffset + (offsetDifference * dragPercentage) 147 | 148 | bottomConstraint?.constant = newOffset 149 | 150 | // If we've gone past the half state height, start transitioning the view height 151 | if newOffset <= CardState.half.bottomOffset { 152 | let excessDrag = abs(newOffset - CardState.half.bottomOffset) 153 | let heightDifference = CardState.full.height - CardState.half.height 154 | let heightDragPercentage = excessDrag / 400 155 | 156 | let newHeight = CardState.half.height + (heightDifference * heightDragPercentage) 157 | heightConstraint?.constant = newHeight 158 | mainHeightConstraint?.constant = newHeight 159 | bottomConstraint?.constant = CardState.half.bottomOffset 160 | } 161 | } else { 162 | 163 | } 164 | 165 | case .half: 166 | // Dragging up to full 167 | if dragAmount < 0 { 168 | let halfHeight = CardState.half.height 169 | let fullHeight = CardState.full.height 170 | let heightDifference = fullHeight - halfHeight 171 | 172 | let newHeight = halfHeight + (heightDifference * dragPercentage) 173 | 174 | if newHeight > CardState.full.height { 175 | // Apply rubber banding when exceeding full height 176 | let excess = newHeight - CardState.full.height 177 | let dampedExcess = excess * 0.2 178 | let dampedHeight = CardState.full.height + dampedExcess 179 | 180 | heightConstraint?.constant = dampedHeight 181 | mainHeightConstraint?.constant = dampedHeight 182 | } else { 183 | heightConstraint?.constant = newHeight 184 | mainHeightConstraint?.constant = newHeight 185 | } 186 | 187 | bottomConstraint?.constant = CardState.half.bottomOffset 188 | 189 | // Dragging down to peek 190 | } else { 191 | let currentOffset: CGFloat = CardState.half.bottomOffset 192 | let targetOffset: CGFloat = CardState.peek.bottomOffset 193 | let offsetDifference = targetOffset - currentOffset 194 | let newBottomOffset = currentOffset + (offsetDifference * dragPercentage) 195 | 196 | bottomConstraint?.constant = newBottomOffset 197 | } 198 | 199 | case .full: 200 | if dragAmount < 0 { 201 | let fullHeight = CardState.full.height 202 | let screenHeight = GlobalConstants.screenH 203 | let heightDifference = screenHeight - fullHeight 204 | 205 | let newHeight = fullHeight + (heightDifference * dragPercentage) 206 | 207 | let excess = newHeight - CardState.full.height 208 | let dampedExcess = excess * 0.2 209 | let dampedHeight = CardState.full.height + dampedExcess 210 | 211 | heightConstraint?.constant = dampedHeight 212 | mainHeightConstraint?.constant = dampedHeight 213 | } else { 214 | let fullHeight = CardState.full.height 215 | let halfHeight = CardState.half.height 216 | let heightDifference = halfHeight - fullHeight 217 | 218 | let newHeight = fullHeight + (heightDifference * dragPercentage) 219 | 220 | if newHeight < CardState.half.height { 221 | let excess = newHeight - CardState.half.height 222 | let dampedExcess = excess * 0.2 223 | let dampedHeight = CardState.half.height + dampedExcess 224 | 225 | heightConstraint?.constant = dampedHeight 226 | mainHeightConstraint?.constant = dampedHeight 227 | 228 | let newDragAmount = max(0, translation.y - CardState.half.height + 12) 229 | let newDragPercentage = abs(newDragAmount) / 400 230 | 231 | let currentOffset: CGFloat = CardState.half.bottomOffset 232 | let targetOffset: CGFloat = CardState.peek.bottomOffset 233 | let offsetDifference = targetOffset - currentOffset 234 | let newBottomOffset = currentOffset + (offsetDifference * newDragPercentage) 235 | 236 | bottomConstraint?.constant = newBottomOffset 237 | } else { 238 | heightConstraint?.constant = newHeight 239 | mainHeightConstraint?.constant = newHeight 240 | } 241 | } 242 | } 243 | 244 | case .ended: 245 | switch currentState { 246 | case .peek: 247 | if dragAmount < -CardState.half.height { 248 | updateLayout(for: .full) 249 | } else if dragVelocity < -500 || dragAmount < -50 { 250 | updateLayout(for: .half) 251 | } else { 252 | updateLayout(for: .peek) 253 | } 254 | 255 | case .half: 256 | if dragVelocity > 500 || dragAmount > 50 { 257 | updateLayout(for: .peek) 258 | } else if dragVelocity < -500 || dragAmount < -50 { 259 | updateLayout(for: .full) 260 | } else { 261 | updateLayout(for: .half) 262 | } 263 | 264 | case .full: 265 | if dragAmount > CardState.half.height { 266 | updateLayout(for: .peek) 267 | } else if dragVelocity > 500 || dragAmount > 50 { 268 | updateLayout(for: .half) 269 | } else { 270 | updateLayout(for: .full) 271 | } 272 | } 273 | 274 | default: 275 | break 276 | } 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /YUI/Demos/ModalCard/ModalCardView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class ModalCardView: UIViewController, ViewControllerIdentifiable { 4 | var stringIdentifier: String = "ModalCardView" 5 | var nameIdentifier: String = "Modal Card" 6 | 7 | override func viewDidLoad() { 8 | super.viewDidLoad() 9 | setupView() 10 | setupInteractiveCard() 11 | setupBackButton() 12 | } 13 | 14 | override func viewDidAppear(_ animated: Bool) { 15 | super.viewDidAppear(animated) 16 | } 17 | 18 | func setupView() { 19 | view.backgroundColor = .white 20 | } 21 | 22 | func setupInteractiveCard() { 23 | let cardView = ModalCard() 24 | 25 | view.addSubview(cardView) 26 | NSLayoutConstraint.activate([ 27 | cardView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 28 | cardView.trailingAnchor.constraint(equalTo: view.trailingAnchor), 29 | cardView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) 30 | ]) 31 | 32 | cardView.updateLayout(for: .half, animated: false) 33 | } 34 | 35 | private func setupBackButton() { 36 | let backButton = BackButton(blurStyle: .systemUltraThinMaterialDark) 37 | backButton.backNavigation = { [weak self] in 38 | self?.navigationController?.popViewController(animated: true) 39 | } 40 | 41 | backButton.then { 42 | view.addSubview($0) 43 | }.layout { 44 | $0.leading == view.leadingAnchor + 20 45 | $0.bottom == view.safeAreaLayoutGuide.bottomAnchor - 20 46 | } 47 | } 48 | } 49 | 50 | extension ModalCardView: HomeTransitioning { 51 | var sharedView: UIView? { 52 | return UIView() 53 | } 54 | 55 | override var prefersHomeIndicatorAutoHidden: Bool { 56 | return true 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /YUI/Demos/Path/PathCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | struct PathItem { 4 | let date: Date 5 | let relativeDate: String 6 | let username: String 7 | let description: String 8 | let location: String? 9 | let iconType: IconType? 10 | let reactionCount: Int? 11 | 12 | enum IconType { 13 | case location 14 | case sun 15 | } 16 | } 17 | 18 | final class PathItemCell: UICollectionViewCell { 19 | private let avatarView: UIView = { 20 | let avatarView = UIView() 21 | avatarView.backgroundColor = getRandomColor(withHueRange: 0.0...0.1) 22 | avatarView.contentMode = .scaleAspectFill 23 | avatarView.clipsToBounds = true 24 | avatarView.layer.cornerRadius = 20 25 | return avatarView 26 | }() 27 | 28 | private let iconContainer: UIView = { 29 | let view = UIView() 30 | view.backgroundColor = .clear 31 | return view 32 | }() 33 | 34 | private let iconImageView: UIImageView = { 35 | let imageView = UIImageView() 36 | imageView.contentMode = .scaleAspectFit 37 | imageView.tintColor = .white 38 | return imageView 39 | }() 40 | 41 | private let contentStackView: UIStackView = { 42 | let stack = UIStackView() 43 | stack.axis = .horizontal 44 | stack.spacing = 12 45 | stack.alignment = .top 46 | return stack 47 | }() 48 | 49 | private let textStackView: UIStackView = { 50 | let stack = UIStackView() 51 | stack.axis = .vertical 52 | stack.spacing = 4 53 | return stack 54 | }() 55 | 56 | private let descriptionLabel: UILabel = { 57 | let label = UILabel() 58 | label.font = .systemFont(ofSize: 17) 59 | label.textColor = .black 60 | label.numberOfLines = 0 61 | return label 62 | }() 63 | 64 | private let locationLabel: UILabel = { 65 | let label = UILabel() 66 | label.font = .systemFont(ofSize: 15) 67 | label.textColor = .systemGray 68 | return label 69 | }() 70 | 71 | private let reactionButton: UIButton = { 72 | let button = UIButton(type: .system) 73 | button.setImage(UIImage(systemName: "face.smiling"), for: .normal) 74 | button.tintColor = .black.withAlphaComponent(0.2) 75 | return button 76 | }() 77 | 78 | private let reactionCountLabel: UILabel = { 79 | let label = UILabel() 80 | label.font = .systemFont(ofSize: 15) 81 | label.textColor = .black.withAlphaComponent(0.2) 82 | return label 83 | }() 84 | 85 | override init(frame: CGRect) { 86 | super.init(frame: frame) 87 | setupViews() 88 | } 89 | 90 | required init?(coder: NSCoder) { 91 | fatalError("init(coder:) has not been implemented") 92 | } 93 | 94 | private func setupViews() { 95 | contentView.addSubview(contentStackView) 96 | 97 | avatarView.layout { 98 | $0.width == 40 99 | $0.height == 40 100 | } 101 | 102 | iconContainer.layout { 103 | $0.width == 24 104 | $0.height == 24 105 | } 106 | iconContainer.layer.cornerRadius = 12 107 | iconContainer.addSubview(iconImageView) 108 | 109 | iconImageView.layout { 110 | $0.centerX == iconContainer.centerXAnchor 111 | $0.width == 16 112 | $0.height == 16 113 | } 114 | 115 | contentStackView.layout { 116 | $0.leading == contentView.leadingAnchor + 16 117 | $0.trailing == contentView.trailingAnchor - 16 118 | $0.top == contentView.topAnchor + 16 119 | $0.bottom == contentView.bottomAnchor - 16 120 | } 121 | 122 | contentStackView.addArrangedSubview(avatarView) 123 | 124 | let rightContentStack = UIStackView() 125 | rightContentStack.axis = .horizontal 126 | rightContentStack.spacing = 8 127 | rightContentStack.alignment = .top 128 | 129 | contentStackView.addArrangedSubview(rightContentStack) 130 | 131 | rightContentStack.addArrangedSubview(textStackView) 132 | textStackView.addArrangedSubview(descriptionLabel) 133 | textStackView.addArrangedSubview(locationLabel) 134 | 135 | let reactionStack = UIStackView() 136 | reactionStack.axis = .horizontal 137 | reactionStack.spacing = 4 138 | reactionStack.alignment = .center 139 | 140 | rightContentStack.addArrangedSubview(reactionStack) 141 | reactionStack.addArrangedSubview(reactionButton) 142 | reactionStack.addArrangedSubview(reactionCountLabel) 143 | 144 | let bottomBorder = CALayer() 145 | bottomBorder.backgroundColor = UIColor.black.withAlphaComponent(0.05).cgColor 146 | bottomBorder.frame = CGRect(x: 0, 147 | y: contentView.bounds.height - 1, 148 | width: contentView.bounds.width, 149 | height: 1) 150 | contentView.layer.addSublayer(bottomBorder) 151 | contentView.layer.masksToBounds = true 152 | } 153 | 154 | override func layoutSubviews() { 155 | super.layoutSubviews() 156 | 157 | // Update border frame when cell size changes 158 | if let bottomBorder = contentView.layer.sublayers?.first(where: { $0.frame.height == 1 }) 159 | { 160 | bottomBorder.frame = CGRect(x: 0, 161 | y: contentView.bounds.height - 1, 162 | width: contentView.bounds.width, 163 | height: 1) 164 | } 165 | } 166 | 167 | func configure(with item: PathItem) { 168 | descriptionLabel.text = item.description 169 | locationLabel.text = item.location 170 | locationLabel.isHidden = item.location == nil 171 | 172 | if let iconType = item.iconType { 173 | iconContainer.isHidden = false 174 | switch iconType { 175 | case .sun: 176 | iconContainer.backgroundColor = .systemYellow 177 | iconImageView.image = UIImage(systemName: "sun.max.fill") 178 | case .location: 179 | iconContainer.backgroundColor = .systemBlue 180 | iconImageView.image = UIImage(systemName: "location.fill") 181 | } 182 | } else { 183 | iconContainer.isHidden = true 184 | } 185 | 186 | if let reactionCount = item.reactionCount { 187 | reactionButton.isHidden = false 188 | reactionCountLabel.isHidden = false 189 | reactionCountLabel.text = "\(reactionCount)" 190 | } else { 191 | reactionButton.isHidden = true 192 | reactionCountLabel.isHidden = true 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /YUI/Demos/Path/PathClockTooltip.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class PathClockTooltip: UIView { 4 | private lazy var gradientLayer: CAGradientLayer = { 5 | let layer = CAGradientLayer() 6 | layer.colors = [ 7 | UIColor.black.withAlphaComponent(0.7).cgColor, 8 | UIColor.black.cgColor 9 | ] 10 | layer.locations = [0.0, 1.0] 11 | return layer 12 | }() 13 | 14 | private lazy var pathTooltipLayer: CAShapeLayer = { 15 | let layer = CAShapeLayer() 16 | layer.fillColor = UIColor.black.cgColor 17 | return layer 18 | }() 19 | 20 | private lazy var clockContainerView: UIView = { 21 | let view = UIView() 22 | view.backgroundColor = .white 23 | return view 24 | }() 25 | 26 | private lazy var clockImageView: UIImageView = { 27 | let imageView = UIImageView() 28 | imageView.image = UIImage(named: "Clock") 29 | imageView.contentMode = .scaleAspectFill 30 | imageView.layer.opacity = 0.5 31 | return imageView 32 | }() 33 | 34 | private lazy var hourHand: UIView = { 35 | let view = UIView() 36 | view.backgroundColor = .pathRed 37 | return view 38 | }() 39 | 40 | private lazy var minuteHand: UIView = { 41 | let view = UIView() 42 | view.backgroundColor = .pathRed 43 | return view 44 | }() 45 | 46 | private lazy var clockCenterDetail: UIView = { 47 | let view = UIView() 48 | view.backgroundColor = .white 49 | view.layer.borderColor = UIColor.pathRed.cgColor 50 | view.layer.borderWidth = 1.5 51 | view.layer.cornerRadius = 3 52 | return view 53 | }() 54 | 55 | private lazy var timeLabel: UILabel = { 56 | let label = UILabel() 57 | label.font = .systemFont(ofSize: 12, weight: .medium) 58 | label.textColor = .white 59 | label.textAlignment = .left 60 | label.numberOfLines = 2 61 | return label 62 | }() 63 | 64 | override init(frame: CGRect) { 65 | super.init(frame: frame) 66 | setupView() 67 | } 68 | 69 | required init?(coder: NSCoder) { 70 | fatalError("init(coder:) has not been implemented") 71 | } 72 | 73 | private func setupView() { 74 | backgroundColor = .clear 75 | 76 | layer.insertSublayer(gradientLayer, at: 0) 77 | layer.mask = pathTooltipLayer 78 | 79 | addSubview(timeLabel) 80 | addSubview(clockContainerView) 81 | clockContainerView.addSubview(clockImageView) 82 | clockContainerView.addSubview(hourHand) 83 | clockContainerView.addSubview(minuteHand) 84 | clockContainerView.addSubview(clockCenterDetail) 85 | 86 | clockContainerView.translatesAutoresizingMaskIntoConstraints = false 87 | clockImageView.translatesAutoresizingMaskIntoConstraints = false 88 | clockCenterDetail.translatesAutoresizingMaskIntoConstraints = false 89 | hourHand.translatesAutoresizingMaskIntoConstraints = false 90 | minuteHand.translatesAutoresizingMaskIntoConstraints = false 91 | timeLabel.translatesAutoresizingMaskIntoConstraints = false 92 | 93 | NSLayoutConstraint.activate([ 94 | clockContainerView.centerYAnchor.constraint(equalTo: centerYAnchor), 95 | clockContainerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 5), 96 | clockContainerView.widthAnchor.constraint(equalTo: heightAnchor, constant: -10), 97 | clockContainerView.heightAnchor.constraint(equalTo: heightAnchor, constant: -10), 98 | 99 | clockImageView.centerXAnchor.constraint(equalTo: clockContainerView.centerXAnchor), 100 | clockImageView.centerYAnchor.constraint(equalTo: clockContainerView.centerYAnchor), 101 | clockImageView.widthAnchor.constraint(equalTo: clockContainerView.widthAnchor), 102 | clockImageView.heightAnchor.constraint(equalTo: clockContainerView.heightAnchor), 103 | 104 | clockCenterDetail.centerXAnchor.constraint(equalTo: clockContainerView.centerXAnchor), 105 | clockCenterDetail.centerYAnchor.constraint(equalTo: clockContainerView.centerYAnchor), 106 | clockCenterDetail.widthAnchor.constraint(equalToConstant: 6), 107 | clockCenterDetail.heightAnchor.constraint(equalToConstant: 6), 108 | 109 | hourHand.centerXAnchor.constraint(equalTo: clockImageView.centerXAnchor), 110 | hourHand.centerYAnchor.constraint(equalTo: clockImageView.centerYAnchor), 111 | hourHand.widthAnchor.constraint(equalToConstant: 3), 112 | hourHand.heightAnchor.constraint(equalTo: clockImageView.heightAnchor, multiplier: 0.3), 113 | 114 | minuteHand.centerXAnchor.constraint(equalTo: clockImageView.centerXAnchor), 115 | minuteHand.centerYAnchor.constraint(equalTo: clockImageView.centerYAnchor), 116 | minuteHand.widthAnchor.constraint(equalToConstant: 3), 117 | minuteHand.heightAnchor.constraint(equalTo: clockImageView.heightAnchor, multiplier: 0.4), 118 | 119 | timeLabel.topAnchor.constraint(equalTo: topAnchor, constant: 4), 120 | timeLabel.leadingAnchor.constraint(equalTo: clockContainerView.trailingAnchor, constant: 8), 121 | timeLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), 122 | timeLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) 123 | ]) 124 | 125 | setupClockHands() 126 | } 127 | 128 | private func setupClockHands() { 129 | hourHand.layer.anchorPoint = CGPoint(x: 0.5, y: 1) 130 | minuteHand.layer.anchorPoint = CGPoint(x: 0.5, y: 1) 131 | 132 | hourHand.layer.cornerRadius = 1.5 133 | minuteHand.layer.cornerRadius = 1.5 134 | 135 | updateClockHands(Date()) 136 | } 137 | 138 | override func layoutSubviews() { 139 | super.layoutSubviews() 140 | 141 | updateTooltipPath() 142 | clockContainerView.layer.cornerRadius = clockContainerView.bounds.height / 2 143 | gradientLayer.frame = bounds 144 | 145 | if let currentTime = (timeLabel.text?.components(separatedBy: "\n").first).flatMap({ timeString in 146 | let formatter = DateFormatter() 147 | formatter.dateFormat = "h:mm a" 148 | return formatter.date(from: timeString) 149 | }) { 150 | updateClockHands(currentTime) 151 | } 152 | } 153 | 154 | private func updateTooltipPath() { 155 | let path = UIBezierPath() 156 | let radius = bounds.height / 2 157 | let caratWidth: CGFloat = 12 158 | 159 | path.move(to: CGPoint(x: radius, y: 0)) // Begin with top left point 160 | path.addLine(to: CGPoint(x: bounds.width - caratWidth, y: 0)) // Draw top edge 161 | path.addLine(to: CGPoint(x: bounds.width, y: bounds.height / 2)) // Draw carat... 162 | path.addLine(to: CGPoint(x: bounds.width - caratWidth, y: bounds.height)) // Complete carat... 163 | path.addLine(to: CGPoint(x: radius, y: bounds.height)) // Draw bottom edge 164 | path.addArc(withCenter: CGPoint(x: radius, y: bounds.height / 2), 165 | radius: radius, 166 | startAngle: .pi / 2, 167 | endAngle: -.pi / 2, 168 | clockwise: true) // Draw arc for clock to rest in 169 | path.close() 170 | 171 | pathTooltipLayer.path = path.cgPath 172 | } 173 | 174 | private func updateClockHands(_ date: Date) { 175 | let calendar = Calendar.current 176 | let hour = CGFloat(calendar.component(.hour, from: date) % 12) 177 | let minute = CGFloat(calendar.component(.minute, from: date)) 178 | 179 | let hourAngle = ((hour + minute / 60) / 12) * 2 * .pi 180 | let minuteAngle = (minute / 60) * 2 * .pi 181 | 182 | hourHand.transform = CGAffineTransform(rotationAngle: hourAngle) 183 | minuteHand.transform = CGAffineTransform(rotationAngle: minuteAngle) 184 | } 185 | 186 | func updateTime(date: Date, text: String) { 187 | UIView.animate(withDuration: 0.4, 188 | delay: 0, 189 | usingSpringWithDamping: 2, 190 | initialSpringVelocity: 0.2, 191 | options: [.beginFromCurrentState]) 192 | { 193 | self.timeLabel.text = text 194 | self.updateClockHands(date) 195 | self.layoutIfNeeded() 196 | } 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /YUI/Demos/Path/PathView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class PathView: UIViewController, ViewControllerIdentifiable { 4 | var stringIdentifier: String = "PathView" 5 | var nameIdentifier: String = "Path" 6 | 7 | private let transitionAnimator = HomeTransitionAnimationController() 8 | public var selectedIndexPath: IndexPath? 9 | 10 | private lazy var clockTooltip = PathClockTooltip() 11 | private var tooltipTimer: Timer? 12 | private var pathItems: [PathItem] = [] 13 | 14 | private lazy var layout: UICollectionViewFlowLayout = { 15 | let layout = UICollectionViewFlowLayout() 16 | layout.scrollDirection = .vertical 17 | layout.minimumLineSpacing = 0 18 | layout.minimumInteritemSpacing = 0 19 | layout.sectionInset = UIEdgeInsets(top: view.safeAreaInsets.top, left: 0, 20 | bottom: view.safeAreaInsets.bottom + 120, right: 0) 21 | return layout 22 | }() 23 | 24 | private lazy var collectionView: UICollectionView = { 25 | let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) 26 | collectionView.backgroundColor = .pathBackground 27 | collectionView.register(PathItemCell.self, forCellWithReuseIdentifier: PathItemCell.identifier) 28 | collectionView.delegate = self 29 | collectionView.dataSource = self 30 | collectionView.indicatorStyle = .black 31 | collectionView.scrollIndicatorInsets = UIEdgeInsets(top: view.safeAreaInsets.top, left: 0, 32 | bottom: view.safeAreaInsets.bottom + 120, right: 0) 33 | return collectionView 34 | }() 35 | 36 | override func viewDidLoad() { 37 | super.viewDidLoad() 38 | setupViews() 39 | setupCells() 40 | setupBackButton() 41 | 42 | clockTooltip.alpha = 0 43 | clockTooltip.transform = CGAffineTransform(translationX: 4, y: 0) 44 | } 45 | 46 | override func viewDidAppear(_ animated: Bool) { 47 | super.viewDidAppear(animated) 48 | navigationController?.navigationBar.isHidden = true 49 | } 50 | 51 | private func setupViews() { 52 | view.layer.masksToBounds = true 53 | view.backgroundColor = UIColor.pathBackground 54 | 55 | collectionView.then { 56 | view.addSubview($0) 57 | }.layout { 58 | $0.top == view.topAnchor 59 | $0.leading == view.leadingAnchor 60 | $0.trailing == view.trailingAnchor 61 | $0.bottom == view.bottomAnchor 62 | } 63 | 64 | clockTooltip.then { 65 | view.addSubview($0) 66 | }.layout { 67 | $0.trailing == view.trailingAnchor - 4 68 | $0.top == view.safeAreaLayoutGuide.topAnchor 69 | $0.height == 40 70 | $0.width == 120 71 | } 72 | } 73 | 74 | private func setupCells() { 75 | for i in 1...100 { 76 | // Generate random date 77 | var date = Calendar.current.date(byAdding: .day, 78 | value: -i * 3, to: Date()) ?? Date() 79 | date = Calendar.current.date(byAdding: .second, 80 | value: Bool.random() ? Int.random(in: -24800...0) : Int.random(in: 0...46572), 81 | to: date) ?? Date() 82 | 83 | // Convert to relative date 84 | let formatter = RelativeDateTimeFormatter() 85 | formatter.unitsStyle = .full 86 | let relativeDate = formatter.localizedString(for: date, relativeTo: Date.now) 87 | 88 | // Generate random text 89 | let description = GlobalConstants.bodyFragments.randomElement()! 90 | let username = GlobalConstants.usernameFragments.randomElement()! 91 | 92 | let item = PathItem( 93 | date: date, 94 | relativeDate: relativeDate, 95 | username: username, 96 | description: description, 97 | location: "Cupertino, CA", 98 | iconType: Bool.random() ? .sun : .location, 99 | reactionCount: Int.random(in: 0...100) 100 | ) 101 | 102 | pathItems.append(item) 103 | } 104 | collectionView.reloadData() 105 | } 106 | 107 | private func setupBackButton() { 108 | let backButton = BackButton(blurStyle: .systemUltraThinMaterialDark) 109 | backButton.backNavigation = { [weak self] in 110 | self?.navigationController?.popViewController(animated: true) 111 | } 112 | 113 | backButton.then { 114 | view.addSubview($0) 115 | }.layout { 116 | $0.leading == view.leadingAnchor + 20 117 | $0.bottom == view.safeAreaLayoutGuide.bottomAnchor - 20 118 | } 119 | } 120 | } 121 | 122 | extension PathView: UICollectionViewDelegateFlowLayout { 123 | func collectionView( 124 | _ collectionView: UICollectionView, 125 | layout collectionViewLayout: UICollectionViewLayout, 126 | sizeForItemAt indexPath: IndexPath 127 | ) -> CGSize { 128 | // Calculate available width, accounting for section insets 129 | let availableWidth = collectionView.bounds.width - layout.sectionInset.left - layout.sectionInset.right 130 | 131 | let cell = PathItemCell() 132 | cell.configure(with: pathItems[indexPath.item]) 133 | 134 | // Calculate the required size using cell contents 135 | let size = cell.contentView.systemLayoutSizeFitting( 136 | CGSize(width: availableWidth, height: UIView.layoutFittingCompressedSize.height), 137 | withHorizontalFittingPriority: .required, 138 | verticalFittingPriority: .fittingSizeLevel 139 | ) 140 | 141 | return CGSize(width: availableWidth, height: size.height) 142 | } 143 | } 144 | 145 | extension PathView: UICollectionViewDelegate, UICollectionViewDataSource, UIScrollViewDelegate { 146 | func collectionView( 147 | _ collectionView: UICollectionView, 148 | numberOfItemsInSection section: Int 149 | ) -> Int { 150 | return pathItems.count 151 | } 152 | 153 | func collectionView( 154 | _ collectionView: UICollectionView, 155 | cellForItemAt indexPath: IndexPath 156 | ) -> UICollectionViewCell { 157 | let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PathItemCell.identifier, 158 | for: indexPath) as! PathItemCell 159 | cell.configure(with: pathItems[indexPath.item]) 160 | return cell 161 | } 162 | } 163 | 164 | extension PathView { 165 | func scrollViewDidScroll(_ scrollView: UIScrollView) { 166 | updateClockTooltipPosition() 167 | } 168 | 169 | func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { 170 | tooltipTimer?.invalidate() 171 | 172 | UIView.animate(withDuration: 0.2) { 173 | self.clockTooltip.alpha = 1 174 | 175 | let currentY = self.clockTooltip.transform.ty 176 | self.clockTooltip.transform = CGAffineTransform( 177 | translationX: -8, 178 | y: currentY 179 | ) 180 | } 181 | } 182 | 183 | func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { 184 | if !decelerate { startOverlayHideTimer() } 185 | } 186 | 187 | func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { 188 | startOverlayHideTimer() 189 | } 190 | 191 | private func startOverlayHideTimer() { 192 | tooltipTimer?.invalidate() 193 | 194 | tooltipTimer = Timer.scheduledTimer(withTimeInterval: 1.0, 195 | repeats: false) 196 | { [weak self] _ in 197 | UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseInOut]) { 198 | self?.clockTooltip.alpha = 0 199 | let currentY = self?.clockTooltip.transform.ty ?? 0 200 | self?.clockTooltip.transform = CGAffineTransform(translationX: 4, y: currentY) 201 | } 202 | } 203 | } 204 | } 205 | 206 | extension PathView { 207 | private func updateClockTooltipPosition() { 208 | guard !pathItems.isEmpty else { return } 209 | 210 | // Calculate tooltip's center point in collection view's coordinate space 211 | let tooltipCenter = CGPoint(x: clockTooltip.frame.minX, y: clockTooltip.frame.midY) 212 | let convertedPoint = view.convert(tooltipCenter, to: collectionView) 213 | 214 | // Find the cell that intersects with the tooltip's position 215 | if let indexPath = collectionView.indexPathForItem(at: convertedPoint), 216 | indexPath.item < pathItems.count 217 | { 218 | let item = pathItems[indexPath.item] 219 | 220 | // Format dates 221 | let timeFormatter = DateFormatter() 222 | timeFormatter.dateFormat = "h:mm a" 223 | let timeStr = timeFormatter.string(from: item.date) 224 | let dateFormatter = DateFormatter() 225 | dateFormatter.dateFormat = "MM/dd/yy" 226 | let dateStr = dateFormatter.string(from: item.date) 227 | 228 | // Update overlay with new info 229 | clockTooltip.updateTime( 230 | date: item.date, 231 | text: "\(timeStr)\n\(dateStr)" 232 | ) 233 | } 234 | 235 | // Calculate max distance tooltip can travel 236 | let bottomInset = view.safeAreaInsets.bottom + 120 237 | let topInset = view.safeAreaInsets.top 238 | let maxTooltipTravel = collectionView.bounds.height - topInset - bottomInset - clockTooltip.bounds.height 239 | 240 | // Calculate normalized scroll progress (0 to 1) 241 | let contentHeight = collectionView.contentSize.height - collectionView.bounds.height 242 | let scrollProgress = min(max(collectionView.contentOffset.y / contentHeight, 0), 1) 243 | 244 | // Translate tooltip to new position 245 | self.clockTooltip.transform = CGAffineTransform( 246 | translationX: -8, 247 | y: scrollProgress * maxTooltipTravel 248 | ) 249 | } 250 | } 251 | 252 | extension PathView: UINavigationControllerDelegate { 253 | func navigationController(_ navigationController: UINavigationController, 254 | animationControllerFor operation: UINavigationController.Operation, 255 | from fromVC: UIViewController, 256 | to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? 257 | { 258 | if toVC is Self { 259 | transitionAnimator.transition = .push 260 | return transitionAnimator 261 | } else if toVC is HomeView, fromVC is Self { 262 | transitionAnimator.transition = .pop 263 | return transitionAnimator 264 | } 265 | return nil 266 | } 267 | } 268 | 269 | extension PathView: HomeTransitioning { 270 | var sharedView: UIView? { 271 | return UIView() 272 | } 273 | 274 | override var prefersHomeIndicatorAutoHidden: Bool { 275 | return true 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /YUI/Demos/TwitterSplashScreen/TwitterSplashScreenView.swift: -------------------------------------------------------------------------------- 1 | // Adapted from https://iosdevtips.co/post/88481653818/twitter-ios-app-bird-zoom-animation 2 | 3 | import UIKit 4 | 5 | final class TwitterSplashScreenView: UIViewController, ViewControllerIdentifiable { 6 | var stringIdentifier: String = "TwitterSplashScreenView" 7 | var nameIdentifier: String = "Twitter Splash Screen" 8 | 9 | private lazy var twitterScreenContainerView: UIView = { 10 | let view = UIView() 11 | view.backgroundColor = .white 12 | return view 13 | }() 14 | 15 | private lazy var twitterScreen: UIImageView = { 16 | guard let image = UIImage(named: "TwitterScreen") else { return UIImageView() } 17 | let imageView = UIImageView(image: image) 18 | imageView.alpha = 0 19 | return imageView 20 | }() 21 | 22 | private lazy var twitterLogoMaskView: UIImageView = { 23 | guard let image = UIImage(named: "TwitterLogo") else { return UIImageView() } 24 | let imageView = UIImageView(image: image) 25 | imageView.contentMode = .scaleAspectFill 26 | return imageView 27 | }() 28 | 29 | override func viewDidLoad() { 30 | super.viewDidLoad() 31 | 32 | setupViews() 33 | setupBackButton() 34 | } 35 | 36 | override func viewWillAppear(_ animated: Bool) { 37 | super.viewWillAppear(animated) 38 | navigationController?.navigationBar.isHidden = true 39 | 40 | // Reset everything to initial state 41 | twitterLogoMaskView.transform = .identity 42 | twitterScreen.alpha = 0 43 | twitterLogoMaskView.frame = CGRect(x: 0, y: 0, width: 72.88, height: 60) 44 | twitterLogoMaskView.center = view.center 45 | twitterScreenContainerView.mask = twitterLogoMaskView 46 | 47 | // Start animation after reset 48 | DispatchQueue.main.async { [weak self] in 49 | self?.startAnimation() 50 | } 51 | } 52 | 53 | private func setupViews() { 54 | view.layer.masksToBounds = true 55 | view.backgroundColor = .twitterBlue 56 | 57 | twitterScreenContainerView.then { 58 | view.addSubview($0) 59 | }.layout { 60 | $0.trailing == view.trailingAnchor 61 | $0.leading == view.leadingAnchor 62 | $0.top == view.topAnchor 63 | $0.bottom == view.bottomAnchor 64 | } 65 | 66 | twitterScreen.do { 67 | twitterScreenContainerView.addSubview($0) 68 | twitterScreenContainerView.fillWith($0) 69 | } 70 | 71 | // Mask Twitter screen with the Twitter logo 72 | twitterLogoMaskView.do { 73 | $0.frame = CGRect(x: 0, y: 0, width: 72.88, height: 60) 74 | $0.center = view.center 75 | twitterScreenContainerView.mask = $0 76 | } 77 | } 78 | 79 | private func startAnimation() { 80 | twitterLogoMaskView.center = view.center 81 | 82 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in 83 | guard let self = self else { return } 84 | 85 | // Shrink Twitter logo slightly 86 | UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseInOut) { 87 | self.twitterLogoMaskView.transform = CGAffineTransform(scaleX: 0.7, y: 0.7) 88 | self.twitterScreenContainerView.transform = CGAffineTransform(scaleX: 1.16, y: 1.16) 89 | } completion: { _ in 90 | // Animate opacity of Twitter screen 91 | UIView.animate(withDuration: 0.4, 92 | delay: 0, 93 | usingSpringWithDamping: 0.8, 94 | initialSpringVelocity: 0.6, 95 | options: .curveEaseInOut) 96 | { 97 | self.twitterScreen.alpha = 1 98 | } 99 | 100 | // Expand Twitter logo to reveal app contents 101 | UIView.animate(withDuration: 0.8, 102 | delay: 0, 103 | usingSpringWithDamping: 0.8, 104 | initialSpringVelocity: 0.6, 105 | options: .curveEaseInOut) 106 | { 107 | self.twitterScreenContainerView.transform = CGAffineTransform(scaleX: 1, y: 1) 108 | self.twitterLogoMaskView.transform = CGAffineTransform(scaleX: 60.0, y: 60.0) 109 | } completion: { _ in 110 | self.twitterScreenContainerView.mask = nil 111 | self.twitterLogoMaskView.removeFromSuperview() 112 | } 113 | } 114 | } 115 | } 116 | 117 | private func setupBackButton() { 118 | let backButton = BackButton(blurStyle: .systemUltraThinMaterialDark) 119 | backButton.backNavigation = { [weak self] in 120 | self?.navigationController?.popViewController(animated: true) 121 | } 122 | 123 | backButton.then { 124 | view.addSubview($0) 125 | }.layout { 126 | $0.leading == view.leadingAnchor + 20 127 | $0.bottom == view.safeAreaLayoutGuide.bottomAnchor - 20 128 | } 129 | } 130 | } 131 | 132 | extension TwitterSplashScreenView: HomeTransitioning { 133 | var sharedView: UIView? { 134 | return UIView() 135 | } 136 | 137 | override var prefersHomeIndicatorAutoHidden: Bool { 138 | return true 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /YUI/Demos/TwitterSwipeGesture/TweetCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | struct Tweet { 4 | let date: Date 5 | let relativeDate: String 6 | let username: String 7 | let description: String 8 | } 9 | 10 | final class TweetCell: UICollectionViewCell { 11 | private lazy var profileImageView: UIView = { 12 | let profileImageView = UIView() 13 | profileImageView.clipsToBounds = true 14 | return profileImageView 15 | }() 16 | 17 | private lazy var headerView: UIView = { 18 | return UIView() 19 | }() 20 | 21 | private lazy var contentStackView: UIStackView = { 22 | let contentStack = UIStackView() 23 | contentStack.axis = .horizontal 24 | contentStack.spacing = 12 25 | contentStack.alignment = .top 26 | return contentStack 27 | }() 28 | 29 | private lazy var textStackView: UIStackView = { 30 | let textStack = UIStackView() 31 | textStack.axis = .vertical 32 | textStack.spacing = 20 33 | return textStack 34 | }() 35 | 36 | private lazy var dotSeparator: UILabel = { 37 | let dotSeparator = UILabel() 38 | dotSeparator.text = "•" 39 | dotSeparator.font = .systemFont(ofSize: 14) 40 | dotSeparator.textColor = .gray 41 | return dotSeparator 42 | }() 43 | 44 | private lazy var dateLabel: UILabel = { 45 | let dateLabel = UILabel() 46 | dateLabel.font = .systemFont(ofSize: 14) 47 | dateLabel.textColor = .gray 48 | return dateLabel 49 | }() 50 | 51 | 52 | private lazy var usernameLabel: UILabel = { 53 | let usernameLabel = UILabel() 54 | usernameLabel.font = .systemFont(ofSize: 15, weight: .bold) 55 | usernameLabel.textColor = .black 56 | return usernameLabel 57 | }() 58 | 59 | private lazy var descriptionLabel: UILabel = { 60 | let descriptionLabel = UILabel() 61 | descriptionLabel.font = .systemFont(ofSize: 15) 62 | descriptionLabel.textColor = .black 63 | descriptionLabel.numberOfLines = 0 64 | return descriptionLabel 65 | }() 66 | 67 | override init(frame: CGRect) { 68 | super.init(frame: frame) 69 | setupViews() 70 | } 71 | 72 | required init?(coder: NSCoder) { 73 | fatalError("init(coder:) has not been implemented") 74 | } 75 | 76 | private func setupViews() { 77 | backgroundColor = .white 78 | 79 | profileImageView.then { 80 | $0.layer.cornerRadius = 20 81 | }.layout { 82 | $0.width == 40 83 | $0.height == 40 84 | } 85 | 86 | usernameLabel.then { 87 | headerView.addSubview($0) 88 | }.layout { 89 | $0.top == headerView.topAnchor 90 | $0.leading == headerView.leadingAnchor 91 | } 92 | 93 | dotSeparator.then { 94 | headerView.addSubview($0) 95 | }.layout { 96 | $0.top == headerView.topAnchor 97 | $0.leading == usernameLabel.trailingAnchor + 4 98 | } 99 | 100 | dateLabel.then { 101 | headerView.addSubview($0) 102 | }.layout { 103 | $0.top == headerView.topAnchor 104 | $0.leading == dotSeparator.trailingAnchor + 4 105 | } 106 | 107 | textStackView.do { 108 | $0.addArrangedSubview(headerView) 109 | $0.addArrangedSubview(descriptionLabel) 110 | } 111 | 112 | contentStackView.do { 113 | $0.addArrangedSubview(profileImageView) 114 | $0.addArrangedSubview(textStackView) 115 | } 116 | 117 | contentView.do { 118 | $0.addSubview(contentStackView) 119 | } 120 | 121 | contentStackView.layout { 122 | $0.top == contentView.topAnchor + 12 123 | $0.leading == contentView.leadingAnchor + 16 124 | $0.trailing == contentView.trailingAnchor - 16 125 | $0.bottom == contentView.bottomAnchor - 12 126 | } 127 | 128 | let divider = UIView() 129 | divider.then { 130 | $0.backgroundColor = .twitterGray 131 | contentView.addSubview($0) 132 | }.layout { 133 | $0.leading == contentView.leadingAnchor 134 | $0.trailing == contentView.trailingAnchor 135 | $0.bottom == contentView.bottomAnchor 136 | $0.height == 0.5 137 | } 138 | } 139 | 140 | func configure(with item: Tweet) { 141 | usernameLabel.text = item.username 142 | dateLabel.text = item.relativeDate 143 | descriptionLabel.text = item.description 144 | profileImageView.backgroundColor = getRandomColor(withHueRange: 0.6...0.7) 145 | 146 | setNeedsLayout() 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /YUI/Demos/[untitled]/Transition/[untitled]TransitionAnimationController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class UntitledTransitionAnimationController: NSObject { 4 | var transition: TransitionType = .push 5 | private var config: SharedTransitionConfig = .untitled 6 | } 7 | 8 | extension UntitledTransitionAnimationController: UIViewControllerAnimatedTransitioning { 9 | func transitionDuration(using transitionContext: (any UIViewControllerContextTransitioning)?) -> TimeInterval 10 | { 11 | config.duration 12 | } 13 | 14 | func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) 15 | { 16 | prepareViewControllers(from: transitionContext, for: transition) 17 | 18 | switch transition { 19 | case .push: 20 | pushAnimation(with: transitionContext) 21 | case .pop: 22 | popAnimation(with: transitionContext) 23 | } 24 | } 25 | } 26 | 27 | extension UntitledTransitionAnimationController { 28 | private func pushAnimation(with context: UIViewControllerContextTransitioning) { 29 | guard let (fromView, fromFrame, fromSharedView, 30 | toView, toFrame, toSharedView) = setup(with: context) else 31 | { 32 | context.completeTransition(false) 33 | return 34 | } 35 | 36 | let sharedViewTransform: CGAffineTransform = .transform(originalFrame: fromSharedView.frame, 37 | toTargetFrame: toSharedView.frame) 38 | 39 | // Setup duplicate album art view for transitioning 40 | context.containerView.addSubview(fromSharedView) 41 | 42 | let fromTransform: CGAffineTransform = .init(translationX: -GlobalConstants.screenW, y: 0) 43 | let toTransform: CGAffineTransform = .init(translationX: GlobalConstants.screenW, y: 0) 44 | toView.transform = toTransform 45 | 46 | let fromPlaceholder = UIView().then { 47 | $0.backgroundColor = config.placeholderColor 48 | $0.frame = CGRect(origin: fromFrame.origin, 49 | size: CGSize(width: fromFrame.width + 2, 50 | height: fromFrame.height + 2)) 51 | } 52 | fromView.addSubview(fromPlaceholder) 53 | 54 | let toPlaceholder = UIView().then { 55 | $0.backgroundColor = config.placeholderColor 56 | $0.frame = toFrame 57 | } 58 | toView.addSubview(toPlaceholder) 59 | 60 | // So that fromView can fade into the background instead of black 61 | let backdrop = UIView().then { 62 | $0.backgroundColor = .untitledGrey 63 | $0.frame = fromView.frame 64 | } 65 | context.containerView.insertSubview(backdrop, at: 0) 66 | 67 | UIView.animate( 68 | withDuration: 0.4, 69 | delay: 0, 70 | usingSpringWithDamping: 2, 71 | initialSpringVelocity: 0.4, 72 | options: []) 73 | { 74 | toView.transform = .identity 75 | fromView.transform = fromTransform 76 | fromView.layer.opacity = 0 77 | fromSharedView.transform = sharedViewTransform 78 | } completion: { _ in 79 | fromView.transform = .identity 80 | fromView.layer.opacity = 1 81 | 82 | fromSharedView.removeFromSuperview() 83 | fromPlaceholder.removeFromSuperview() 84 | toPlaceholder.removeFromSuperview() 85 | backdrop.removeFromSuperview() 86 | 87 | context.completeTransition(true) 88 | } 89 | } 90 | 91 | private func popAnimation(with context: UIViewControllerContextTransitioning) { 92 | guard let (fromView, fromFrame, fromSharedView, 93 | toView, toFrame, toSharedView) = setup(with: context) else 94 | { 95 | context.completeTransition(false) 96 | return 97 | } 98 | 99 | let sharedViewTransform: CGAffineTransform = .transform(originalFrame: fromSharedView.frame, 100 | toTargetFrame: toSharedView.frame) 101 | 102 | // Setup duplicate album art view for transitioning 103 | context.containerView.addSubview(fromSharedView) 104 | 105 | let toTransform: CGAffineTransform = .init(translationX: -GlobalConstants.screenW, y: 0) 106 | toView.transform = toTransform 107 | toView.layer.opacity = 0 108 | 109 | let fromTransform: CGAffineTransform = .init(translationX: GlobalConstants.screenW, y: 0) 110 | 111 | let fromPlaceholder = UIView().then { 112 | $0.backgroundColor = config.placeholderColor 113 | $0.frame = fromFrame 114 | } 115 | fromView.addSubview(fromPlaceholder) 116 | 117 | let toPlaceholder = UIView().then { 118 | $0.backgroundColor = config.placeholderColor 119 | $0.frame = CGRect(origin: toFrame.origin, 120 | size: CGSize(width: toFrame.width + 2, 121 | height: toFrame.height + 2)) 122 | } 123 | toView.addSubview(toPlaceholder) 124 | 125 | // So that toView can fade from the background instead of black for some reason 126 | let backdrop = UIView().then { 127 | $0.backgroundColor = .untitledGrey 128 | $0.frame = fromView.frame 129 | } 130 | context.containerView.insertSubview(backdrop, at: 0) 131 | 132 | let animation = { 133 | toView.layer.opacity = 1 134 | toView.transform = .identity 135 | fromView.transform = fromTransform 136 | fromSharedView.transform = sharedViewTransform 137 | } 138 | 139 | let completion = { 140 | fromPlaceholder.removeFromSuperview() 141 | toPlaceholder.removeFromSuperview() 142 | fromSharedView.removeFromSuperview() 143 | backdrop.removeFromSuperview() 144 | 145 | let isCancelled = context.transitionWasCancelled 146 | if isCancelled { // Reset necessary states to initial values 147 | fromView.transform = .identity 148 | toView.transform = .identity 149 | } 150 | 151 | context.completeTransition(!isCancelled) 152 | } 153 | 154 | if context.isInteractive { 155 | UIView.animate(duration: 0.4, curve: config.curve) { animation() } completion: { completion() } 156 | } else { 157 | UIView.animate( 158 | withDuration: 0.4, 159 | delay: 0, 160 | usingSpringWithDamping: 1, 161 | initialSpringVelocity: 0, 162 | options: [.beginFromCurrentState]) 163 | { 164 | animation() 165 | } completion: { _ in 166 | completion() 167 | } 168 | } 169 | } 170 | 171 | private func prepareViewControllers(from context: UIViewControllerContextTransitioning, 172 | for transition: TransitionType) 173 | { 174 | let fromVC = context.viewController(forKey: .from) as? SharedTransitioning 175 | let toVC = context.viewController(forKey: .to) as? SharedTransitioning 176 | 177 | if let customConfig = fromVC?.config { config = customConfig } 178 | 179 | fromVC?.prepare(for: transition) 180 | toVC?.prepare(for: transition) 181 | } 182 | 183 | private func setup(with context: UIViewControllerContextTransitioning) -> (UIView, CGRect, UIView, UIView, CGRect, UIView)? 184 | { 185 | guard let toView = context.view(forKey: .to), 186 | let fromView = context.view(forKey: .from) else 187 | { 188 | return nil 189 | } 190 | 191 | if transition == .push { 192 | context.containerView.addSubview(toView) 193 | } else { 194 | context.containerView.insertSubview(toView, belowSubview: fromView) 195 | } 196 | 197 | guard let toFrame = context.sharedFrame(forKey: .to), 198 | let fromFrame = context.sharedFrame(forKey: .from) else 199 | { 200 | return nil 201 | } 202 | 203 | guard let toSharedView = context.sharedView(forKey: .to), 204 | let fromSharedView = context.sharedView(forKey: .from) else 205 | { 206 | return nil 207 | } 208 | 209 | return (fromView, fromFrame, fromSharedView, toView, toFrame, toSharedView) 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /YUI/Demos/[untitled]/ViewControllers/[untitled]DetailView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class UntitledDetailView: UIViewController { 4 | private enum Constants { 5 | static let scrollViewInset: UIEdgeInsets = .init(top: 78, left: 20, 6 | bottom: 20, right: 20) 7 | } 8 | 9 | private var image: UIImage 10 | 11 | private let scrollView = UIScrollView() 12 | private let contentView = UIView() 13 | private let header = UntitledDetailViewHeader() 14 | private let imageView = UIImageView() 15 | private let transitionAnimator = UntitledTransitionAnimationController() 16 | private var interactionController: UIPercentDrivenInteractiveTransition? 17 | private lazy var panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) 18 | 19 | init(image: UIImage) { 20 | self.image = image 21 | 22 | super.init(nibName: nil, bundle: nil) 23 | 24 | self.imageView.contentMode = .scaleAspectFit 25 | self.imageView.backgroundColor = .untitledGrey 26 | self.imageView.layer.cornerCurve = .continuous 27 | self.imageView.layer.cornerRadius = 39 // So it matches the transition 28 | self.imageView.accessibilityIgnoresInvertColors = true 29 | 30 | self.imageView.image = image 31 | } 32 | 33 | required init?(coder: NSCoder) { 34 | fatalError("init(coder:) has not been implemented") 35 | } 36 | 37 | override func viewDidLoad() { 38 | super.viewDidLoad() 39 | navigationController?.navigationBar.isHidden = true 40 | setupView() 41 | } 42 | 43 | override func viewDidAppear(_ animated: Bool) { 44 | super.viewDidAppear(animated) 45 | navigationController?.delegate = self 46 | } 47 | } 48 | 49 | extension UntitledDetailView { 50 | private func setupView() { 51 | view.backgroundColor = .untitledGrey 52 | 53 | // Add pan gesture recognizer that will activate the interactive pop transition 54 | view.addGestureRecognizer(panGestureRecognizer) 55 | panGestureRecognizer.delegate = self 56 | 57 | setupScrollView() 58 | setupImageView() 59 | setupHeader() 60 | } 61 | 62 | private func setupHeader() { 63 | header.then { 64 | $0.backNavigation = { [weak self] in 65 | self?.navigationController?.popViewController(animated: true) 66 | } 67 | $0.backgroundColor = .clear 68 | view.addSubview($0) 69 | }.layout { 70 | $0.top == view.safeAreaLayoutGuide.topAnchor 71 | $0.leading == view.leadingAnchor 72 | $0.trailing == view.trailingAnchor 73 | } 74 | } 75 | 76 | private func setupScrollView() { 77 | scrollView.then { 78 | $0.alwaysBounceVertical = true 79 | view.addSubview($0) 80 | }.layout { 81 | $0.top == view.topAnchor 82 | $0.leading == view.leadingAnchor 83 | $0.trailing == view.trailingAnchor 84 | $0.bottom == view.bottomAnchor 85 | } 86 | 87 | contentView.then { 88 | scrollView.addSubview($0) 89 | }.layout { 90 | $0.top == scrollView.contentLayoutGuide.topAnchor 91 | $0.leading == scrollView.contentLayoutGuide.leadingAnchor 92 | $0.trailing == scrollView.contentLayoutGuide.trailingAnchor 93 | $0.bottom == scrollView.contentLayoutGuide.bottomAnchor 94 | $0.width == scrollView.frameLayoutGuide.widthAnchor 95 | } 96 | } 97 | 98 | private func setupImageView() { 99 | imageView.then { 100 | contentView.addSubview($0) 101 | $0.contentMode = .scaleAspectFill 102 | $0.layer.masksToBounds = true 103 | $0.image = image 104 | }.layout { 105 | $0.leading == contentView.leadingAnchor + Constants.scrollViewInset.right 106 | $0.trailing == contentView.trailingAnchor - Constants.scrollViewInset.right 107 | $0.top == contentView.safeAreaLayoutGuide.topAnchor + Constants.scrollViewInset.top 108 | } 109 | 110 | // Fix album art to square ratio 111 | imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor).isActive = true 112 | } 113 | 114 | @objc func handlePan(_ recognizer: UIPanGestureRecognizer) { 115 | let contentOffsetY = scrollView.contentOffset.y 116 | 117 | let window = UIApplication.keyWindow! 118 | 119 | switch recognizer.state { 120 | case .began: 121 | let velocity = recognizer.velocity(in: window) 122 | guard abs(velocity.x) > abs(velocity.y) else { return } 123 | interactionController = UIPercentDrivenInteractiveTransition() 124 | navigationController?.popViewController(animated: true) 125 | 126 | scrollView.isScrollEnabled = false 127 | 128 | case .changed: 129 | let translation = recognizer.translation(in: window) 130 | let progress = translation.x / window.frame.width 131 | 132 | interactionController?.update(progress) 133 | 134 | case .ended: 135 | let horizontalVelocity = recognizer.velocity(in: window).x 136 | let translation = recognizer.translation(in: window) 137 | let progress = translation.x / window.frame.width 138 | 139 | if horizontalVelocity > 900 || progress > 0.1 && !(horizontalVelocity <= 0) { 140 | interactionController?.finish() 141 | } else { 142 | interactionController?.completionSpeed = progress 143 | interactionController?.cancel() 144 | } 145 | 146 | interactionController = nil 147 | 148 | scrollView.isScrollEnabled = true 149 | 150 | default: 151 | interactionController?.cancel() 152 | interactionController = nil 153 | 154 | scrollView.isScrollEnabled = true 155 | } 156 | 157 | scrollView.contentOffset.y = contentOffsetY 158 | } 159 | } 160 | 161 | extension UntitledDetailView: UINavigationControllerDelegate { 162 | func navigationController(_ navigationController: UINavigationController, 163 | animationControllerFor operation: UINavigationController.Operation, 164 | from fromVC: UIViewController, 165 | to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? 166 | { 167 | // Use default animation if not going to PhotoGridView 168 | guard fromVC is Self, toVC is UntitledGridView else { return nil } 169 | 170 | transitionAnimator.transition = .pop 171 | return transitionAnimator 172 | } 173 | 174 | func navigationController( 175 | _ navigationController: UINavigationController, 176 | interactionControllerFor animationController: UIViewControllerAnimatedTransitioning 177 | ) -> UIViewControllerInteractiveTransitioning? { 178 | interactionController 179 | } 180 | } 181 | 182 | extension UntitledDetailView: UIGestureRecognizerDelegate { 183 | func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, 184 | shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool 185 | { 186 | return true 187 | } 188 | } 189 | 190 | extension UntitledDetailView: SharedTransitioning { 191 | var sharedFrame: CGRect { 192 | imageView.frameInWindow ?? .zero 193 | } 194 | 195 | var sharedView: UIView? { 196 | // Recreate a snapshot of the cell instead of returning the cell itself 197 | let snapshotView = UIView(frame: imageView.frameInWindow ?? imageView.frame) 198 | let imageView = UIImageView(image: image) 199 | imageView.do { 200 | $0.contentMode = .scaleAspectFill 201 | $0.layer.masksToBounds = true 202 | $0.layer.cornerCurve = .continuous 203 | $0.layer.cornerRadius = 40 204 | snapshotView.addSubview($0) 205 | snapshotView.fillWith($0) 206 | } 207 | 208 | return snapshotView 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /YUI/Demos/[untitled]/ViewControllers/[untitled]GridView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class UntitledGridView: UIViewController, ViewControllerIdentifiable { 4 | var stringIdentifier: String = "UntitledGridView" 5 | var nameIdentifier: String = "[untitled]" 6 | 7 | private enum Constants { 8 | static let numberOfCols = 2 9 | static let sectionInset: UIEdgeInsets = .init(top: 72, left: 32, 10 | bottom: 32, right: 32) 11 | static let interItemSpacing: CGFloat = 32 12 | static let lineSpacing: CGFloat = 40 13 | } 14 | 15 | private let transitionAnimator = UntitledTransitionAnimationController() 16 | private let fbPaperTransitionAnimator = HomeTransitionAnimationController() 17 | private let header = UntitledGridViewHeader(title: "[untitled]") 18 | private var selectedIndexPath: IndexPath? 19 | private var albumImages: [UIImage] = [ 20 | UIImage(named: "CarpetGolf")!, 21 | UIImage(named: "CURB")!, 22 | UIImage(named: "Sobs")!, 23 | UIImage(named: "SubsonicEye")!, 24 | UIImage(named: "toe")!, 25 | UIImage(named: "HikaruUtada")!, 26 | UIImage(named: "NoPartyForCaoDong")!, 27 | UIImage(named: "Nujabes")!, 28 | UIImage(named: "PorterRobinson")!, 29 | UIImage(named: "TwoDoorCinemaClub")!, 30 | ] 31 | 32 | private lazy var layout = UICollectionViewFlowLayout().then { 33 | $0.sectionInset = Constants.sectionInset 34 | $0.minimumLineSpacing = Constants.lineSpacing 35 | $0.minimumInteritemSpacing = Constants.interItemSpacing 36 | } 37 | 38 | private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout).then { 39 | $0.register(AlbumCell.self, forCellWithReuseIdentifier: AlbumCell.identifier) 40 | $0.delegate = self 41 | $0.dataSource = self 42 | $0.delaysContentTouches = false 43 | $0.backgroundColor = .white 44 | } 45 | 46 | override func viewDidLoad() { 47 | super.viewDidLoad() 48 | navigationController?.navigationBar.isHidden = true 49 | setupView() 50 | } 51 | 52 | override func viewDidAppear(_ animated: Bool) { 53 | super.viewDidAppear(animated) 54 | navigationController?.delegate = self 55 | } 56 | 57 | private func setupView() { 58 | view.backgroundColor = .untitledGrey 59 | view.layer.cornerCurve = .continuous 60 | view.layer.masksToBounds = true 61 | 62 | setupCollectionView() 63 | setupHeader() 64 | setupBackButton() 65 | } 66 | 67 | private func setupHeader() { 68 | header.then { 69 | view.addSubview($0) 70 | }.layout { 71 | $0.top == view.safeAreaLayoutGuide.topAnchor 72 | $0.leading == view.leadingAnchor 73 | $0.trailing == view.trailingAnchor 74 | } 75 | } 76 | 77 | private func setupCollectionView() { 78 | collectionView.then { 79 | $0.backgroundColor = .untitledGrey 80 | $0.showsVerticalScrollIndicator = false 81 | view.addSubview($0) 82 | }.layout { 83 | $0.leading == view.leadingAnchor 84 | $0.trailing == view.trailingAnchor 85 | $0.top == view.topAnchor 86 | $0.bottom == view.bottomAnchor 87 | } 88 | } 89 | 90 | private func setupBackButton() { 91 | let backButton = BackButton(customTintColor: .white) 92 | backButton.backNavigation = { [weak self] in 93 | self?.navigationController?.popViewController(animated: true) 94 | } 95 | 96 | backButton.then { 97 | view.addSubview($0) 98 | }.layout { 99 | $0.leading == view.leadingAnchor + 20 100 | $0.bottom == view.safeAreaLayoutGuide.bottomAnchor - 20 101 | } 102 | } 103 | } 104 | 105 | extension UntitledGridView: UICollectionViewDataSource { 106 | func numberOfSections(in collectionView: UICollectionView) -> Int { 107 | return 1 108 | } 109 | 110 | func collectionView(_ collectionView: UICollectionView, 111 | numberOfItemsInSection section: Int) -> Int 112 | { 113 | return albumImages.count 114 | } 115 | 116 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell 117 | { 118 | let cell = collectionView.dequeueReusableCell(withReuseIdentifier: AlbumCell.identifier, 119 | for: indexPath) as! AlbumCell 120 | 121 | let albumImage = albumImages[indexPath.item] 122 | cell.setupWithUIImage(with: albumImage) 123 | 124 | return cell 125 | } 126 | } 127 | 128 | extension UntitledGridView: UICollectionViewDelegate { 129 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) 130 | { 131 | selectedIndexPath = indexPath 132 | let image = albumImages[indexPath.item] 133 | let untitledDetailView = UntitledDetailView(image: image) 134 | self.navigationController?.pushViewController(untitledDetailView, animated: true) 135 | } 136 | } 137 | 138 | extension UntitledGridView: UICollectionViewDelegateFlowLayout { 139 | func collectionView(_ collectionView: UICollectionView, 140 | layout collectionViewLayout: UICollectionViewLayout, 141 | sizeForItemAt indexPath: IndexPath) -> CGSize 142 | { 143 | let spacingWidth = CGFloat(Constants.numberOfCols - 1) * Constants.interItemSpacing 144 | let contentWidth = collectionView.frame.inset(by: Constants.sectionInset).width 145 | let availableWidth = contentWidth - spacingWidth 146 | let size = availableWidth / CGFloat(Constants.numberOfCols) 147 | return CGSize(width: size, height: size) 148 | } 149 | } 150 | 151 | extension UntitledGridView: UINavigationControllerDelegate { 152 | func navigationController(_ navigationController: UINavigationController, 153 | animationControllerFor operation: UINavigationController.Operation, 154 | from fromVC: UIViewController, 155 | to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? 156 | { 157 | if fromVC is Self, toVC is UntitledDetailView { 158 | transitionAnimator.transition = .push 159 | return transitionAnimator 160 | } else if toVC is Self, fromVC is UntitledDetailView { 161 | transitionAnimator.transition = .pop 162 | return transitionAnimator 163 | } else if toVC is HomeView, fromVC is Self { 164 | fbPaperTransitionAnimator.transition = .pop 165 | return fbPaperTransitionAnimator 166 | } 167 | 168 | return nil 169 | } 170 | } 171 | 172 | extension UntitledGridView: SharedTransitioning, HomeTransitioning { 173 | var sharedFrame: CGRect { 174 | guard let selectedIndexPath, 175 | let cell = collectionView.cellForItem(at: selectedIndexPath), 176 | let frame = cell.frameInWindow else { return .zero } 177 | return frame 178 | } 179 | 180 | var sharedView: UIView? { 181 | guard let selectedIndexPath, 182 | let cell = collectionView.cellForItem(at: selectedIndexPath) as? AlbumCell else 183 | { 184 | // We don't return nil here to support FBPaperTransitioning 185 | // TODO: Clean up 186 | return UIView() 187 | } 188 | 189 | // Recreate a snapshot of the cell instead of returning the cell itself 190 | let snapshotView = UIView(frame: cell.frameInWindow ?? cell.frame) 191 | let imageView = UIImageView(image: albumImages[selectedIndexPath.item]) 192 | imageView.do { 193 | $0.contentMode = .scaleAspectFill 194 | $0.layer.masksToBounds = true 195 | $0.layer.cornerCurve = .continuous 196 | $0.layer.cornerRadius = 16 197 | snapshotView.addSubview($0) 198 | snapshotView.fillWith($0) 199 | } 200 | 201 | return snapshotView 202 | } 203 | 204 | func prepare(for transition: TransitionType) { 205 | guard transition == .pop, let selectedIndexPath else { return } 206 | collectionView.verticalScrollItemVisible(at: selectedIndexPath, 207 | withPadding: 120, animated: false) 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /YUI/Demos/[untitled]/Views/AlbumCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Photos 3 | 4 | class AlbumCell: UICollectionViewCell { 5 | let imageView = UIImageView() 6 | 7 | override init(frame: CGRect) { 8 | super.init(frame: frame) 9 | setupImageView() 10 | } 11 | 12 | private func setupImageView() { 13 | imageView.do { 14 | $0.contentMode = .scaleAspectFill 15 | $0.layer.masksToBounds = true 16 | $0.layer.cornerCurve = .continuous 17 | $0.layer.cornerRadius = 16 18 | contentView.addSubview($0) 19 | contentView.fillWith($0) 20 | } 21 | } 22 | 23 | required init?(coder: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | 27 | func setupWithUIImage(with img: UIImage) { 28 | imageView.image = img 29 | } 30 | 31 | override func prepareForReuse() { 32 | super.prepareForReuse() 33 | imageView.image = nil 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /YUI/Demos/[untitled]/Views/[untitled]DetailViewHeader.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class UntitledDetailViewHeader: UIView { 4 | private let stackView = UIStackView() 5 | private let backButton = UIButton(configuration: .plain()) 6 | private let rightStackView = UIStackView() 7 | private let linkButton = UIButton(configuration: .plain()) 8 | private let moreButton = UIButton(configuration: .plain()) 9 | 10 | var backNavigation: (() -> Void)? 11 | var linkNavigation: (() -> Void)? 12 | var moreNavigation: (() -> Void)? 13 | 14 | private var backAction: UIAction { 15 | UIAction(handler: { [weak self] _ in self?.backNavigation?() }) 16 | } 17 | 18 | private var linkAction: UIAction { 19 | UIAction(handler: { [weak self] _ in self?.linkNavigation?() }) 20 | } 21 | 22 | private var moreAction: UIAction { 23 | UIAction(handler: { [weak self] _ in self?.moreNavigation?() }) 24 | } 25 | 26 | override init(frame: CGRect) { 27 | super.init(frame: .zero) 28 | setupView() 29 | } 30 | 31 | required init?(coder: NSCoder) { 32 | fatalError("init(coder:) has not been implemented") 33 | } 34 | } 35 | 36 | extension UntitledDetailViewHeader { 37 | private func setupView() { 38 | setupStackView() 39 | setupBackButton() 40 | setupRightButtons() 41 | } 42 | 43 | private func setupStackView() { 44 | stackView.do { 45 | addSubview($0) 46 | fillWith($0, insets: .init(top: 20, left: 20, bottom: 20, right: 20)) 47 | $0.axis = .horizontal 48 | $0.alignment = .center 49 | $0.distribution = .equalSpacing 50 | } 51 | } 52 | 53 | private func setupBackButton() { 54 | backButton.do { 55 | let configuration = UIImage.SymbolConfiguration(pointSize: 12, weight: .heavy) 56 | let image = UIImage(systemName: "chevron.left", withConfiguration: configuration) 57 | $0.setImage(image, for: .normal) 58 | $0.tintColor = .white 59 | $0.addAction(backAction, for: .touchUpInside) 60 | $0.setContentHuggingPriority(.required, for: .horizontal) 61 | $0.backgroundColor = UIColor(white: 0.2, alpha: 1.0) 62 | $0.layer.cornerCurve = .continuous 63 | $0.layer.cornerRadius = 16 64 | $0.widthAnchor.constraint(equalToConstant: 44).isActive = true 65 | $0.heightAnchor.constraint(equalToConstant: 44).isActive = true 66 | 67 | stackView.addArrangedSubview($0) 68 | } 69 | } 70 | 71 | private func setupRightButtons() { 72 | rightStackView.do { 73 | $0.axis = .horizontal 74 | $0.spacing = 8 75 | $0.alignment = .center 76 | stackView.addArrangedSubview($0) 77 | } 78 | 79 | linkButton.do { 80 | let configuration = UIImage.SymbolConfiguration(pointSize: 12, weight: .heavy) 81 | let image = UIImage(systemName: "link", withConfiguration: configuration) 82 | $0.setImage(image, for: .normal) 83 | $0.tintColor = .white 84 | $0.addAction(linkAction, for: .touchUpInside) 85 | $0.backgroundColor = UIColor(white: 0.2, alpha: 1.0) 86 | $0.layer.cornerCurve = .continuous 87 | $0.layer.cornerRadius = 16 88 | $0.widthAnchor.constraint(equalToConstant: 44).isActive = true 89 | $0.heightAnchor.constraint(equalToConstant: 44).isActive = true 90 | 91 | rightStackView.addArrangedSubview($0) 92 | } 93 | 94 | moreButton.do { 95 | let configuration = UIImage.SymbolConfiguration(pointSize: 12, weight: .heavy) 96 | let image = UIImage(systemName: "ellipsis", withConfiguration: configuration) 97 | $0.setImage(image, for: .normal) 98 | $0.tintColor = .white 99 | $0.addAction(moreAction, for: .touchUpInside) 100 | $0.backgroundColor = UIColor(white: 0.2, alpha: 1.0) 101 | $0.layer.cornerCurve = .continuous 102 | $0.layer.cornerRadius = 16 103 | $0.widthAnchor.constraint(equalToConstant: 44).isActive = true 104 | $0.heightAnchor.constraint(equalToConstant: 44).isActive = true 105 | 106 | rightStackView.addArrangedSubview($0) 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /YUI/Demos/[untitled]/Views/[untitled]GridViewHeader.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class UntitledGridViewHeader: UIView { 4 | private let stackView = UIStackView() 5 | private let titleLabel = UILabel() 6 | 7 | init(title: String) { 8 | super.init(frame: .zero) 9 | setupView() 10 | titleLabel.text = title 11 | } 12 | 13 | override init(frame: CGRect) { 14 | super.init(frame: .zero) 15 | setupView() 16 | } 17 | 18 | required init?(coder: NSCoder) { 19 | fatalError("init(coder:) has not been implemented") 20 | } 21 | } 22 | 23 | extension UntitledGridViewHeader { 24 | private func setupView() { 25 | backgroundColor = .clear 26 | isUserInteractionEnabled = false 27 | 28 | setupStackView() 29 | setupTitleLabel() 30 | } 31 | 32 | private func setupStackView() { 33 | stackView.do { 34 | addSubview($0) 35 | fillWith($0, insets: .init(top: 20, left: 20, bottom: 20, right: 20)) 36 | $0.axis = .horizontal 37 | $0.alignment = .center 38 | } 39 | } 40 | 41 | private func setupTitleLabel() { 42 | titleLabel.do { 43 | $0.font = .systemFont(ofSize: 20, weight: .bold) 44 | $0.textColor = .white 45 | $0.setContentHuggingPriority(.required, for: .horizontal) 46 | stackView.addArrangedSubview($0) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /YUI/HomeView/HomeTransitioning.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol HomeTransitioning { 4 | var sharedView: UIView? { get } 5 | } 6 | 7 | extension UIViewControllerContextTransitioning { 8 | func sharedViewForFBPaper(forKey key: UITransitionContextViewControllerKey) -> UIView? 9 | { 10 | let viewController = viewController(forKey: key) 11 | viewController?.view.layoutIfNeeded() 12 | 13 | let sharedView = (viewController as? HomeTransitioning)?.sharedView 14 | return sharedView 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /YUI/HomeView/HomeViewCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class HomeViewCell: UICollectionViewCell { 4 | public let imageContainer = UIView() 5 | public let titleLabel = UILabel() 6 | 7 | override init(frame: CGRect) { 8 | super.init(frame: frame) 9 | setupViews() 10 | } 11 | 12 | private func setupViews() { 13 | titleLabel.then { 14 | $0.textAlignment = .left 15 | addSubview($0) 16 | }.layout { 17 | $0.top == topAnchor 18 | $0.leading == leadingAnchor + 20 19 | $0.trailing == trailingAnchor 20 | $0.height == 40 21 | } 22 | 23 | imageContainer.then { 24 | $0.backgroundColor = .white.withAlphaComponent(0.2) 25 | $0.layer.cornerCurve = .continuous 26 | $0.layer.cornerRadius = max(0, UIScreen.main.displayCornerRadius - 32) 27 | $0.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] 28 | $0.clipsToBounds = true 29 | addSubview($0) 30 | }.layout { 31 | $0.top == titleLabel.bottomAnchor 32 | $0.leading == leadingAnchor 33 | $0.trailing == trailingAnchor 34 | $0.bottom == bottomAnchor 35 | } 36 | } 37 | 38 | func configure(image: UIImage?, title: String?) { 39 | // Clean up existing image 40 | imageContainer.subviews.forEach { $0.removeFromSuperview() } 41 | 42 | // Add new image if available 43 | if let image = image { 44 | let imageView = UIImageView(image: image) 45 | imageView.do { 46 | $0.contentMode = .scaleAspectFill 47 | imageContainer.addSubview($0) 48 | imageContainer.fillWith($0) 49 | } 50 | } 51 | 52 | let titleAS = NSAttributedString( 53 | string: title!, 54 | attributes: [ 55 | NSAttributedString.Key.kern: -0.2, 56 | NSAttributedString.Key.font: UIFont.systemFont(ofSize: 16, weight: .regular), 57 | NSAttributedString.Key.foregroundColor: UIColor.white.withAlphaComponent(0.9) 58 | ] 59 | ) 60 | titleLabel.attributedText = titleAS 61 | } 62 | 63 | override func layoutSubviews() { 64 | super.layoutSubviews() 65 | 66 | layer.cornerCurve = .continuous 67 | layer.cornerRadius = max(0, UIScreen.main.displayCornerRadius - 32) 68 | layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] 69 | } 70 | 71 | override func prepareForReuse() { 72 | super.prepareForReuse() 73 | imageContainer.subviews.forEach { $0.removeFromSuperview() } 74 | titleLabel.text = nil 75 | } 76 | 77 | required init?(coder: NSCoder) { 78 | fatalError("init(coder:) has not been implemented") 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /YUI/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | UIWindowSceneSessionRoleApplication 12 | 13 | 14 | UISceneConfigurationName 15 | Default Configuration 16 | UISceneDelegateClassName 17 | $(PRODUCT_MODULE_NAME).SceneDelegate 18 | UISceneStoryboardFile 19 | LaunchScreen 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /YUI/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 4 | var window: UIWindow? 5 | 6 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 7 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 8 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 9 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 10 | guard let windowScene = (scene as? UIWindowScene) else { return } 11 | self.window = UIWindow(windowScene: windowScene) 12 | 13 | // Set the rootViewController 14 | let vc = HomeView() 15 | let nav = UINavigationController(rootViewController: vc) 16 | self.window?.rootViewController = nav 17 | 18 | window?.makeKeyAndVisible() 19 | } 20 | 21 | func sceneDidDisconnect(_ scene: UIScene) { 22 | // Called as the scene is being released by the system. 23 | // This occurs shortly after the scene enters the background, or when its session is discarded. 24 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 25 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 26 | } 27 | 28 | func sceneDidBecomeActive(_ scene: UIScene) { 29 | // Called when the scene has moved from an inactive state to an active state. 30 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 31 | } 32 | 33 | func sceneWillResignActive(_ scene: UIScene) { 34 | // Called when the scene will move from an active state to an inactive state. 35 | // This may occur due to temporary interruptions (ex. an incoming phone call). 36 | } 37 | 38 | func sceneWillEnterForeground(_ scene: UIScene) { 39 | // Called as the scene transitions from the background to the foreground. 40 | // Use this method to undo the changes made on entering the background. 41 | } 42 | 43 | func sceneDidEnterBackground(_ scene: UIScene) { 44 | // Called as the scene transitions from the foreground to the background. 45 | // Use this method to save data, release shared resources, and store enough scene-specific state information 46 | // to restore the scene back to its current state. 47 | } 48 | 49 | 50 | } 51 | 52 | -------------------------------------------------------------------------------- /YUI/Transition/SharedTransitioning.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// This generic protocol is used to enable shared element transitions between view controllers. 4 | /// It defines the following requirements: 5 | /// 6 | /// `sharedFrame`: Gets the frame of the shared element in window coordinates 7 | /// `sharedView`: Gets the view of the shared element in window coordinates. 8 | /// `config`: Optional configuration for the transition 9 | /// `prepare`: A method to prepare the view controller just before transitioning 10 | /// 11 | protocol SharedTransitioning { 12 | var sharedFrame: CGRect { get } 13 | var sharedView: UIView? { get } 14 | var config: SharedTransitionConfig? { get } 15 | func prepare(for transition: TransitionType) 16 | } 17 | 18 | /// Default methods and properties of the `SharedTransitioning` protocol. 19 | /// They're optional so view controllers don't need to implement them if they don't have to. 20 | /// This means conforming types only need to implement `sharedFrame`. 21 | /// 22 | extension SharedTransitioning { 23 | var sharedView: UIView? { nil } 24 | var config: SharedTransitionConfig? { nil } 25 | func prepare(for transition: TransitionType) {} 26 | } 27 | 28 | extension UIViewControllerContextTransitioning { 29 | /// A helper method that takes a transition context key (from/to view controller) and 30 | /// grabs the shared **frame** from the view controller(s) that will be used during the custom transition. 31 | /// 32 | /// We need this to calculate the starting and ending positions of a view during a transition animation. 33 | /// For example, when transitioning from a grid of photos to a detail view, it determines where the image 34 | /// is located in both screens. 35 | /// 36 | /// It returns the `sharedFrame` if the view controller conforms to `SharedTransitioning`. 37 | /// 38 | func sharedFrame(forKey key: UITransitionContextViewControllerKey) -> CGRect? { 39 | let viewController = viewController(forKey: key) 40 | 41 | // We need layoutIfNeeded() here because: 42 | // 1. The view hierarchy might not be fully laid out yet 43 | // 2. Any pending Auto Layout changes need to be applied 44 | // 3. Without it, frame calculations might be incorrect or zero 45 | viewController?.view.layoutIfNeeded() 46 | 47 | return (viewController as? SharedTransitioning)?.sharedFrame 48 | } 49 | 50 | /// A helper method that takes a transition context key (from/to view controller) and 51 | /// grabs the shared **view** from the view controller(s) that will be used during the custom transition. 52 | /// 53 | /// Essentially, this will be a fake "duplicated" view that appears to seamlessly travel across from the source 54 | /// view to the destination view, and only exists during the transition. 55 | /// 56 | /// It returns the `sharedView` if the view controller conforms to `SharedTransitioning` and 57 | /// requires it for the transition. 58 | /// 59 | func sharedView(forKey key: UITransitionContextViewControllerKey) -> UIView? { 60 | let viewController = viewController(forKey: key) 61 | viewController?.view.layoutIfNeeded() 62 | 63 | let sharedView = (viewController as? SharedTransitioning)?.sharedView 64 | return sharedView 65 | } 66 | } 67 | 68 | /// This is a configuration struct that controls various aspects of the shared element transition animation. 69 | /// 70 | /// `duration`: How long the transition animation takes 71 | /// `curve`: The timing function that controls the animation's acceleration/deceleration 72 | /// `maskCornerRadius`: Controls the corner radius of the transitioning view 73 | /// `overlayOpacity`: The opacity of the backdrop during transition 74 | /// `interactionScaleFactor`: Controls max scale factor depending on interactive or default transition 75 | /// `placeholderColor`: The color of the final frame of the transitioning view. 76 | /// 77 | public struct SharedTransitionConfig { 78 | var duration: CGFloat 79 | var curve: CAMediaTimingFunction 80 | var maskCornerRadius: CGFloat 81 | var overlayOpacity: Float 82 | var interactionScaleFactor: CGFloat = .zero 83 | var placeholderColor: UIColor 84 | } 85 | 86 | extension SharedTransitionConfig { 87 | // Default configuration for non-interactive transitions 88 | static var `default`: SharedTransitionConfig { 89 | .init( 90 | duration: 0.25, 91 | curve: CAMediaTimingFunction(controlPoints: 0.25, 0.46, 0.45, 0.94), 92 | maskCornerRadius: UIScreen.main.displayCornerRadius, 93 | overlayOpacity: 0.5, 94 | placeholderColor: .white 95 | ) 96 | } 97 | 98 | // Configuration for interactive transitions (e.g., pan gesture) 99 | static var interactive: SharedTransitionConfig { 100 | .init( 101 | duration: 0.25, 102 | curve: CAMediaTimingFunction(controlPoints: 0.25, 0.46, 0.45, 0.94), 103 | maskCornerRadius: UIScreen.main.displayCornerRadius, 104 | overlayOpacity: 0.5, 105 | interactionScaleFactor: 0.6, 106 | placeholderColor: .white 107 | ) 108 | } 109 | 110 | // Configuration for [untitled] demo transition 111 | static var untitled: SharedTransitionConfig { 112 | .init( 113 | duration: 0.35, 114 | curve: CAMediaTimingFunction(controlPoints: 0.25, 0.46, 0.45, 0.94), 115 | maskCornerRadius: 0, 116 | overlayOpacity: 0, 117 | placeholderColor: .untitledGrey 118 | ) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /YUI/Transition/TransitionType.swift: -------------------------------------------------------------------------------- 1 | /// Convenience enum for determining the transition types for our custom transitions 2 | /// 3 | enum TransitionType { 4 | case push 5 | case pop 6 | } 7 | -------------------------------------------------------------------------------- /YUI/Utils/CGAffineTransform+Extensions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension CGAffineTransform { 4 | /// Basic transform that combines scaling and translation 5 | /// 6 | static func transform(from frameA: CGRect, to frameB: CGRect) -> Self { 7 | let scale = scale(from: frameA, to: frameB) 8 | let translation = translate(from: frameA, to: frameB) 9 | return scale.concatenating(translation) 10 | } 11 | 12 | /// Calculates the translation needed to move from center of `frameA` to center of `frameB` 13 | /// 14 | static func translate(from frameA: CGRect, to frameB: CGRect) -> Self { 15 | let centerA = CGPoint(x: frameA.midX, y: frameA.midY) 16 | let centerB = CGPoint(x: frameB.midX, y: frameB.midY) 17 | return CGAffineTransform( 18 | translationX: centerB.x - centerA.x, 19 | y: centerB.y - centerA.y 20 | ) 21 | } 22 | 23 | /// Calculates the scale factor needed to make `frameA` match `frameB`'s size 24 | /// 25 | static func scale(from frameA: CGRect, to frameB: CGRect) -> Self { 26 | let scaleX = frameB.width / frameA.width 27 | let scaleY = frameB.height / frameA.height 28 | return CGAffineTransform(scaleX: scaleX, y: scaleY) 29 | } 30 | 31 | /// A function that calculates the transformations required based on the three 32 | /// participating rects in a `SharedTransition`. 33 | /// 34 | /// - `parent`: The container view's frame 35 | /// - `child`: The view within the parent that we want to transform 36 | /// - `targetRect`: The target frame we want the child to match exactly 37 | /// 38 | /// For example, given the following two views that we want to transition between: 39 | /// 40 | /* 41 | +-------+ +-------------+ 42 | | | | B | 43 | | A | +-------------+ 44 | | | | | 45 | +-------+ | | 46 | | C | 47 | | | 48 | | | 49 | +-------------+ 50 | | B | 51 | +-------------+ 52 | */ 53 | /// 54 | /// The `parent` is B, the `child` is C (the rect we aim to overlap with another rect 55 | /// post-transformation), and the `rect` is A (the rect whose geometry we want the child to match 56 | /// with exactly). 57 | /// 58 | /// However, this function is not the one we shoud use, as the aspect ratio alters as A goes to C and back. 59 | /// This results in a distorted view since they are stretched and reshaped. The other 60 | /// `transform(parent:suchThatChild:aspectFills)` function is better suited for our needs. 61 | /// 62 | static func transform(parent: CGRect, 63 | suchThatChild child: CGRect, 64 | matches targetRect: CGRect) -> Self 65 | { 66 | // Calculate scale factors by comparing child rectangle's (C) dimensions 67 | // with the target rectangle (A) 68 | let scaleX = targetRect.width / child.width 69 | let scaleY = targetRect.height / child.height 70 | 71 | // Determine the origin of the animation by aligning the 72 | // centers of the rects. 73 | // 74 | // First, we match the center of the parent rect to the target rect. 75 | // 76 | // Second, we adjust the offset so that the child rect's center is aligned 77 | // with the target (calculated as the diff between the centers of the 78 | // parent and child rect in their scaled form). 79 | // 80 | // Without this, the view will always transition from the center of 81 | // the screen, instead of from where the transition should begin (i.e. 82 | // from a cell in a collection view). 83 | let offsetX = targetRect.midX - parent.midX 84 | let offsetY = targetRect.midY - parent.midY 85 | let centerOffsetX = (parent.midX - child.midX) * scaleX 86 | let centerOffsetY = (parent.midY - child.midY) * scaleY 87 | let translateX = offsetX + centerOffsetX 88 | let translateY = offsetY + centerOffsetY 89 | 90 | // Finally, create and combine the transformations into one 91 | // CGAffineTransform, ready to be applied to our views. 92 | let scaleTransform = CGAffineTransform(scaleX: scaleX, y: scaleY) 93 | let translateTransform = CGAffineTransform(translationX: translateX, 94 | y: translateY) 95 | 96 | return scaleTransform.concatenating(translateTransform) 97 | } 98 | 99 | /// A similar transform function as the one above, but employing `aspectFill` to circumvent 100 | /// distortion of views during transition. With this, the child rect retains its aspect ratio while being transformed 101 | /// to fit perfectly within the target rect. 102 | /// 103 | /// However, this introduces another problem: parts of the child rect now extend beyond the bounds of the target rect 104 | /// and this is especially visible during the last few frames of the animation. We will need to apply a mask during the 105 | /// transition to hide this. Please see `CGRect+Extensions.swift` for the relevant utility functions. 106 | /// 107 | static func transform(parent: CGRect, 108 | suchThatChild child: CGRect, 109 | aspectFills targetRect: CGRect) -> Self 110 | { 111 | // Calculate aspect ratio of both child and target rect frames 112 | let childRatio = child.width / child.height 113 | let rectRatio = targetRect.width / targetRect.height 114 | 115 | let scaleX = targetRect.width / child.width 116 | let scaleY = targetRect.height / child.height 117 | 118 | // Determine the scaling dimension based on a comparison of the two ratios, 119 | // ensuring we maintain the original aspect while fitting the rectangle. 120 | let scaleFactor = rectRatio < childRatio ? scaleY : scaleX 121 | 122 | let offsetX = targetRect.midX - parent.midX 123 | let offsetY = targetRect.midY - parent.midY 124 | let centerOffsetX = (parent.midX - child.midX) * scaleFactor 125 | let centerOffsetY = (parent.midY - child.midY) * scaleFactor 126 | 127 | let translateX = offsetX + centerOffsetX 128 | let translateY = offsetY + centerOffsetY 129 | 130 | let scaleTransform = CGAffineTransform(scaleX: scaleFactor, y: scaleFactor) 131 | let translateTransform = CGAffineTransform(translationX: translateX, 132 | y: translateY) 133 | 134 | return scaleTransform.concatenating(translateTransform) 135 | } 136 | 137 | /// Transform a frame into another frame. Assumes they're the same aspect ratio. 138 | static func transform(originalFrame: CGRect, 139 | toTargetFrame targetFrame: CGRect) -> Self 140 | { 141 | // Width or height doesn't matter if they're the same aspect ratio 142 | let scaleFactor = targetFrame.width / originalFrame.width 143 | 144 | let offsetX = targetFrame.midX - originalFrame.midX 145 | let offsetY = targetFrame.midY - originalFrame.midY 146 | 147 | let scaleTransform = CGAffineTransform(scaleX: scaleFactor, y: scaleFactor) 148 | let translateTransform = CGAffineTransform(translationX: offsetX, 149 | y: offsetY) 150 | 151 | return scaleTransform.concatenating(translateTransform) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /YUI/Utils/CGRect+Extensions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension CGRect { 4 | /// Main `aspectFit` method that decides whether to fit by width or height. 5 | /// Used by the mask view in `SharedTransitionAnimationController`. 6 | /// 7 | func aspectFit(to frame: CGRect) -> CGRect { 8 | let ratio = width / height 9 | let frameRatio = frame.width / frame.height 10 | 11 | // If target frame is narrower than original, fit to width, 12 | // else if target frame is wider than original, fit to height 13 | if frameRatio < ratio { 14 | return aspectFitWidth(to: frame) 15 | } else { 16 | return aspectFitHeight(to: frame) 17 | } 18 | } 19 | 20 | // Fits the rect to the target frame's width while maintaining aspect ratio, 21 | // and centers the result vertically in the target frame 22 | func aspectFitWidth(to frame: CGRect) -> CGRect { 23 | let ratio = width / height 24 | let height = frame.width * ratio 25 | let offsetY = (frame.height - height) / 2 // Center vertically 26 | let origin = CGPoint(x: frame.origin.x, y: frame.origin.y + offsetY) 27 | let size = CGSize(width: frame.width, height: height) 28 | return CGRect(origin: origin, size: size) 29 | } 30 | 31 | // Fits the rect to the target frame's height while maintaining aspect ratio, 32 | // and cnters the result horizontally in the target frame 33 | func aspectFitHeight(to frame: CGRect) -> CGRect { 34 | let ratio = height / width 35 | let width = frame.height * ratio 36 | let offsetX = (frame.width - width) / 2 // Center horizontally 37 | let origin = CGPoint(x: frame.origin.x + offsetX, y: frame.origin.y) 38 | let size = CGSize(width: width, height: frame.height) 39 | return CGRect(origin: origin, size: size) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /YUI/Utils/CGSize+Extensions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension CGSize { 4 | /// Scales up a point-size CGSize into its pixel representation. 5 | /// 6 | var pixelSize: CGSize { 7 | let scale = UIScreen.main.scale 8 | return CGSize(width: self.width * scale, height: self.height * scale) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /YUI/Utils/ClosureLayout/LayoutAnchor.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol LayoutAnchor { 4 | func constraint(equalTo anchor: Self, constant: CGFloat) -> NSLayoutConstraint 5 | func constraint(greaterThanOrEqualTo anchor: Self, constant: CGFloat) -> NSLayoutConstraint 6 | func constraint(lessThanOrEqualTo anchor: Self, constant: CGFloat) -> NSLayoutConstraint 7 | } 8 | 9 | extension NSLayoutAnchor: LayoutAnchor {} 10 | -------------------------------------------------------------------------------- /YUI/Utils/ClosureLayout/LayoutClosure.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIView { 4 | public func layout(using closure: (LayoutProxy) -> Void) { 5 | translatesAutoresizingMaskIntoConstraints = false 6 | closure(LayoutProxy(view: self)) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /YUI/Utils/ClosureLayout/LayoutDimension.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol LayoutDimension { 4 | func constraint(equalToConstant c: CGFloat) -> NSLayoutConstraint 5 | func constraint(lessThanOrEqualToConstant: CGFloat) -> NSLayoutConstraint 6 | func constraint(greaterThanOrEqualToConstant: CGFloat) -> NSLayoutConstraint 7 | } 8 | 9 | extension NSLayoutDimension: LayoutDimension {} 10 | -------------------------------------------------------------------------------- /YUI/Utils/ClosureLayout/LayoutOperators.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// This files enables comparison operators amongst anchors and adding/subtracting of constants 4 | 5 | public func +(lhs: A, rhs: CGFloat) -> (A, CGFloat) { 6 | return (lhs, rhs) 7 | } 8 | 9 | public func -(lhs: A, rhs: CGFloat) -> (A, CGFloat) { 10 | return (lhs, -rhs) 11 | } 12 | 13 | public func ==(lhs: LayoutProperty, rhs: (A, CGFloat)) { 14 | lhs.equal(to: rhs.0, offsetBy: rhs.1) 15 | } 16 | 17 | public func ==(lhs: LayoutProperty, rhs: A) { 18 | lhs.equal(to: rhs) 19 | } 20 | 21 | public func ==(lhs: LayoutProperty, rhs: CGFloat) { 22 | lhs.equalToConstant(rhs) 23 | } 24 | 25 | public func >=(lhs: LayoutProperty, rhs: (A, CGFloat)) { 26 | lhs.greaterThanOrEqual(to: rhs.0, offsetBy: rhs.1) 27 | } 28 | 29 | public func >=(lhs: LayoutProperty, rhs: A) { 30 | lhs.greaterThanOrEqual(to: rhs) 31 | } 32 | 33 | public func >=(lhs: LayoutProperty, rhs: CGFloat) { 34 | lhs.greaterThanOrEqualToConstant(rhs) 35 | } 36 | 37 | public func <=(lhs: LayoutProperty, rhs: (A, CGFloat)) { 38 | lhs.lessThanOrEqual(to: rhs.0, offsetBy: rhs.1) 39 | } 40 | 41 | public func <=(lhs: LayoutProperty, rhs: A) { 42 | lhs.lessThanOrEqual(to: rhs) 43 | } 44 | 45 | public func <=(lhs: LayoutProperty, rhs: CGFloat) { 46 | lhs.lessThanOrEqualToConstant(rhs) 47 | } 48 | 49 | public func ==(lhs: LayoutProperty, rhs: CGSize) { 50 | lhs.equalToConstant(rhs) 51 | } 52 | -------------------------------------------------------------------------------- /YUI/Utils/ClosureLayout/LayoutProperty.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public struct LayoutProperty { 4 | let anchor: Anchor 5 | } 6 | 7 | extension LayoutProperty where Anchor: LayoutAnchor { 8 | func equal(to otherAnchor: Anchor, offsetBy 9 | constant: CGFloat = 0) 10 | { 11 | anchor.constraint(equalTo: otherAnchor, 12 | constant: constant).isActive = true 13 | } 14 | 15 | func greaterThanOrEqual(to otherAnchor: Anchor, 16 | offsetBy constant: CGFloat = 0) 17 | { 18 | anchor.constraint(greaterThanOrEqualTo: otherAnchor, 19 | constant: constant).isActive = true 20 | } 21 | 22 | func lessThanOrEqual(to otherAnchor: Anchor, 23 | offsetBy constant: CGFloat = 0) 24 | { 25 | anchor.constraint(lessThanOrEqualTo: otherAnchor, 26 | constant: constant).isActive = true 27 | } 28 | } 29 | 30 | extension LayoutProperty where Anchor: LayoutDimension { 31 | func equalToConstant(_ c: CGFloat) { 32 | anchor.constraint(equalToConstant: c).isActive = true 33 | } 34 | 35 | func lessThanOrEqualToConstant(_ c: CGFloat) { 36 | anchor.constraint(lessThanOrEqualToConstant: c).isActive = true 37 | } 38 | 39 | func greaterThanOrEqualToConstant(_ c: CGFloat) { 40 | anchor.constraint(greaterThanOrEqualToConstant: c).isActive = true 41 | } 42 | } 43 | 44 | extension LayoutProperty where Anchor: LayoutSizeDimension { 45 | func equalToConstant(_ c: CGSize) { 46 | let constraints = anchor.constraint(equalToConstant: c) 47 | constraints.height.isActive = true 48 | constraints.width.isActive = true 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /YUI/Utils/ClosureLayout/LayoutProxy.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public class LayoutProxy { 4 | public lazy var leading = LayoutProperty(anchor: view.leadingAnchor) 5 | public lazy var trailing = LayoutProperty(anchor: view.trailingAnchor) 6 | public lazy var top = LayoutProperty(anchor: view.topAnchor) 7 | public lazy var bottom = LayoutProperty(anchor: view.bottomAnchor) 8 | public lazy var width = LayoutProperty(anchor: view.widthAnchor) 9 | public lazy var height = LayoutProperty(anchor: view.heightAnchor) 10 | public lazy var centerX = LayoutProperty(anchor: view.centerXAnchor) 11 | public lazy var centerY = LayoutProperty(anchor: view.centerYAnchor) 12 | public lazy var size = LayoutProperty( 13 | anchor: LayoutSizeDimensionAnchor( 14 | heightAnchor: view.heightAnchor, 15 | widthAnchor: view.widthAnchor 16 | ) 17 | ) 18 | 19 | private let view: UIView 20 | 21 | init(view: UIView) { 22 | self.view = view 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /YUI/Utils/ClosureLayout/LayoutSizeDimension.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol LayoutSizeDimension { 4 | func constraint(equalToConstant c: CGSize) -> (height: NSLayoutConstraint, width: NSLayoutConstraint) 5 | } 6 | 7 | public struct LayoutSizeDimensionAnchor: LayoutSizeDimension { 8 | let heightAnchor: NSLayoutDimension 9 | let widthAnchor: NSLayoutDimension 10 | 11 | public func constraint( 12 | equalToConstant c: CGSize 13 | ) -> ( 14 | height: NSLayoutConstraint, 15 | width: NSLayoutConstraint 16 | ) { 17 | return ( 18 | height: heightAnchor.constraint(equalToConstant: c.height), 19 | width: widthAnchor.constraint(equalToConstant: c.width) 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /YUI/Utils/ClosureLayout/UIView+Fill.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIView { 4 | @discardableResult 5 | public func fillWith( 6 | _ view: UIView, 7 | insets: UIEdgeInsets? = nil 8 | ) -> ( 9 | top: NSLayoutConstraint, 10 | bottom: NSLayoutConstraint, 11 | leading: NSLayoutConstraint, 12 | trailing: NSLayoutConstraint 13 | ) { 14 | view.translatesAutoresizingMaskIntoConstraints = false 15 | addSubview(view) 16 | 17 | let topInset = insets?.top ?? 0 18 | let bottomInset = -(insets?.bottom ?? 0) 19 | let leadingInset = insets?.left ?? 0 20 | let trailingInset = -(insets?.right ?? 0) 21 | 22 | let top = view.topAnchor.constraint(equalTo: topAnchor, constant: topInset) 23 | let bottom = view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: bottomInset) 24 | let leading = view.leadingAnchor.constraint(equalTo: leadingAnchor, constant: leadingInset) 25 | let trailing = view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: trailingInset) 26 | 27 | NSLayoutConstraint.activate([top, bottom, leading, trailing]) 28 | 29 | return (top: top, bottom: bottom, leading: leading, trailing: trailing) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /YUI/Utils/Constants.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class GlobalConstants { 4 | static let screenW = UIScreen.main.bounds.width 5 | static let screenH = UIScreen.main.bounds.height 6 | 7 | static let bodyFragments = [ 8 | "found this tiny kissaten in shimokitazawa where the owner's been serving the same blend since 1973. every cup comes with a small crystal glass of water and he uses a brass kettle that's probably older than i am", "theres something about secondhand record stores in tokyo that hits different. spent hours going through unmarked boxes in basement shops finding obscure city pop albums. the prices written in pencil on masking tape", "this designer from the 80s, shinohara yukiko, did all these incredible album covers for YMO but nobody seems to remember her name now. her use of negative space and typography was way ahead of its time", "listening to susumu hirasawa's soundtrack for paprika on vinyl and noticed all these layers i missed in the digital version. theres this subtle theremin part that only comes through on analog", "the attention to packaging design here is unreal. bought basic kitchen scissors and they came in this perfectly considered box with embossed instructions and a tissue paper wrapper", "stumbled into a place in nakano that only sells ambient music from 1980-1995. owner keeps a detailed journal of every album that passes through. knows the entire history of every obscure release", "picked up this beat-up copy of yellow magic orchestra's technodelic. previous owner wrote detailed notes about every track in the margins of the liner notes. becoming obsessed with their handwriting", "the way japanese designers use paper weight as an intentional choice always gets me. even convenience store receipts feel considered. everything has its own specific thickness and texture", "theres this taxi driver who keeps a perfect curated playlist of 70s jazz fusion. rides in his cab feel like stepping into a lost toshiko akiyoshi recording session", "noticed how department store uniforms here are full design projects. someone put serious thought into how the fabric would age, how the pleats would hold up after months of wear", "found ryuichi sakamoto's early sound installations documented in this architecture magazine from 1982. completely different approach to space and sound than his later work", "the sound design in the tokyo metro is fascinating. every station has its own distinct departure melody. supposedly composed to reduce passenger stress but they're legitimate pieces of music", "looking through old muji catalogs from the 90s. their philosophy about removing excess was so radical then. now it seems obvious but someone had to push for that simplicity", "this book collector in daikanyama showed me their original pressing of toshi ichiyanagi's opera from 1969. the sleeve design alone is a masterpiece of modernist typography", "spotted a guy at the flea market who only sells japanese jazz records from 1968-1972. knows every session musician, every studio, every engineer. encyclopedic knowledge of such a specific slice of time", "the consideration in train station signage here goes so deep. even the emergency exits have this subtle hierarchy of information. someone really thought about moments of panic", "watched this artisan in kyoto spend four hours wrapping one package. every fold had meaning. the negative space was as important as the wrapped object itself", "got lost in nakameguro and found this print shop thats been running the same letterpress since 1950. they only do business cards now but each one takes a week to complete", "the acoustic design of these old shinto shrines is incredible. specific stones placed to reflect sound in exact ways. engineering masquerading as decoration", "theres an entire shop in shimokita dedicated to japanese punk records from 1979-1983. owner has a theory about how the scene evolved based on changes in vinyl quality", "love how kiyoshi awazu's poster designs feel fresh 50 years later. his color choices shouldnt work but they do. keeps catching my eye on gallery walls", "this electronics repair shop in akihabara has a wall of vintage walkman modifications. each one customized for specific albums. pure dedication to the perfect listening experience", "found handwritten production notes from a yellow magic orchestra session. engineer documented every synth setting. pages of meticulous technical poetry", "the way light moves through traditional architecture here isnt an accident. every beam placement calculated for specific times of day. design working with nature", "discovered this guy who only collects japanese ambient records that were made for specific buildings. each one perfectly preserved with photos of the original installation", "the mundane infrastructure here is beautiful. even temporary construction barriers are designed with intention. someone considered the visual rhythm of walking past", "met a record collector who specializes in japanese library music from 1975-1985. showed me tracks that were only meant to be background sound for department stores. surprisingly avant-garde", "fascinating how japanese magazines from the 80s predicted current design trends. seeing layouts that could have been made yesterday. something timeless in the grid systems", "spent hours in this tiny shop that only sells architecture books from 1960-1975. owner has organized them by paper quality rather than subject. makes perfect sense somehow", "the sound design in old japanese arcade games hits different on original hardware. found a spot in nakano where you can play them all. subtle differences in the noise floor" 9 | ] 10 | 11 | static let usernameFragments = [ 12 | "analogdrifter", 13 | "the.basement.noise", 14 | "_ghost_machine", 15 | "mediumrare.jpg", 16 | "n0isecollector", 17 | "ritual.paper", 18 | "silentgeometry_", 19 | "s0ft.archive", 20 | "tape__memory", 21 | "lostsignals92", 22 | "0xide", 23 | "careful.static.waste", 24 | "gentlechaos__", 25 | "voltage98", 26 | "sl0w_scan", 27 | "dustlibrary.eq", 28 | "grey.archival", 29 | "lightl0gic", 30 | "mono__garden__", 31 | "night.signal.drift", 32 | "papermem", 33 | "future_quiet_1992", 34 | "rarestatic", 35 | "softmachine_jp", 36 | "tape.garden.444", 37 | "unkn0wn.folder", 38 | "warmdata__", 39 | "winterlogic.exe", 40 | "yellowarchive95", 41 | "zer0_mem" 42 | ] 43 | 44 | static let colors: [UIColor] = [.red, .orange, .yellow, .green, .blue, .systemPink, .purple, .systemIndigo] 45 | } 46 | -------------------------------------------------------------------------------- /YUI/Utils/PHAsset+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Photos 2 | 3 | extension PHAsset { 4 | /// Retrieve the filename of a photo from the photos library using information from `PHAsset` 5 | /// 6 | var originalFilename: String? { 7 | var fileName: String? 8 | 9 | if #available(iOS 9.0, *) { 10 | let resources = PHAssetResource.assetResources(for: self) 11 | if let resource = resources.first { 12 | fileName = resource.originalFilename 13 | } 14 | } 15 | 16 | if fileName == nil { 17 | /// This is an undocumented workaround that works as of iOS 9.1 18 | fileName = self.value(forKey: "filename") as? String 19 | } 20 | 21 | return fileName 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /YUI/Utils/RandomColor.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// This function generates a random UIColor with: 4 | /// - A random hue value that is customizable via hue range (defaults to 0-1) 5 | /// - Saturation between 0.3 and 0.8 for moderate color intensity 6 | /// - Brightness between 0.7 and 1.0 to ensure visible, vibrant colors 7 | /// 8 | /// The generated colors are designed to be adequately pleasing via: 9 | /// - Optional constrained color palettes via custom hue range 10 | /// - Avoiding overly dull colors (through minimum saturation of 0.3) 11 | /// - Avoiding dark colors (through minimum brightness of 0.7) 12 | /// 13 | /// - Parameter hueRange: The range of possible hue values (default: 0...1.0) 14 | /// - Returns: A UIColor instance with random but controlled HSB values and full opacity (alpha = 1.0) 15 | /// 16 | /// Example Usage: 17 | /// ``` 18 | /// // Generate any random color 19 | /// let randomColor = getRandomColor() 20 | /// 21 | /// // Generate a random color in the blue range 22 | /// let randomBlue = getRandomColor(withHueRange: 0.5...0.7) 23 | /// ``` 24 | /// 25 | func getRandomColor(withHueRange hueRange: ClosedRange = 0...1.0) -> UIColor { 26 | let hue = CGFloat.random(in: hueRange) 27 | let saturation: CGFloat = CGFloat.random(in: 0.2...0.8) 28 | let brightness: CGFloat = CGFloat.random(in: 0.9...1.0) 29 | return UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: 1.0) 30 | } 31 | -------------------------------------------------------------------------------- /YUI/Utils/Then.swift: -------------------------------------------------------------------------------- 1 | // swiftlint:disable all 2 | // The MIT License (MIT) 3 | // 4 | // Copyright (c) 2015 Suyeol Jeon (xoul.kr) 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | 24 | import Foundation 25 | #if !os(Linux) 26 | import CoreGraphics 27 | #endif 28 | #if os(iOS) || os(tvOS) 29 | import UIKit.UIGeometry 30 | #endif 31 | 32 | public protocol Then {} 33 | 34 | extension Then where Self: Any { 35 | 36 | /// Makes it available to set properties with closures just after initializing and copying the value types. 37 | /// 38 | /// let frame = CGRect().with { 39 | /// $0.origin.x = 10 40 | /// $0.size.width = 100 41 | /// } 42 | @inlinable 43 | public func with(_ block: (inout Self) throws -> Void) rethrows -> Self { 44 | var copy = self 45 | try block(©) 46 | return copy 47 | } 48 | 49 | /// Makes it available to execute something with closures. 50 | /// 51 | /// UserDefaults.standard.do { 52 | /// $0.set("devxoul", forKey: "username") 53 | /// $0.set("devxoul@gmail.com", forKey: "email") 54 | /// $0.synchronize() 55 | /// } 56 | @inlinable 57 | public func `do`(_ block: (Self) throws -> Void) rethrows { 58 | try block(self) 59 | } 60 | 61 | } 62 | 63 | extension Then where Self: AnyObject { 64 | 65 | /// Makes it available to set properties with closures just after initializing. 66 | /// 67 | /// let label = UILabel().then { 68 | /// $0.textAlignment = .center 69 | /// $0.textColor = UIColor.black 70 | /// $0.text = "Hello, World!" 71 | /// } 72 | @inlinable 73 | public func then(_ block: (Self) throws -> Void) rethrows -> Self { 74 | try block(self) 75 | return self 76 | } 77 | 78 | } 79 | 80 | extension NSObject: Then {} 81 | 82 | #if !os(Linux) 83 | extension CGPoint: Then {} 84 | extension CGRect: Then {} 85 | extension CGSize: Then {} 86 | extension CGVector: Then {} 87 | #endif 88 | 89 | extension Array: Then {} 90 | extension Dictionary: Then {} 91 | extension Set: Then {} 92 | 93 | #if os(iOS) || os(tvOS) 94 | extension UIEdgeInsets: Then {} 95 | extension UIOffset: Then {} 96 | extension UIRectEdge: Then {} 97 | #endif 98 | // swiftlint:enable all 99 | -------------------------------------------------------------------------------- /YUI/Utils/UIApplication+Extensions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIApplication { 4 | /// This computed property finds and returns the key window of the application 5 | /// 6 | static var keyWindow: UIWindow? { 7 | // 1. Get the shared application instance and its connected scenes 8 | // 2. Convert the scenes to UIWindowScene objects 9 | // 3. Get all windows from these scenes 10 | // 4. Find the first window that is marked as the key window 11 | UIApplication.shared 12 | .connectedScenes 13 | .compactMap { $0 as? UIWindowScene } 14 | .flatMap { $0.windows } 15 | .first { $0.isKeyWindow } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /YUI/Utils/UICollectionReusableView+Extensions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UICollectionReusableView { 4 | /// Gives each cell an identifier that is derived from its `String(describing: self)` 5 | /// 6 | static var identifier: String { 7 | return String(describing: self) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /YUI/Utils/UICollectionView+Extensions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UICollectionView { 4 | /// Enum representing a cell's position based on whether it's 5 | /// beyond the top or bottom of the view, or entirely visible 6 | /// 7 | enum VerticalCellPosition { 8 | case overTop 9 | case visible 10 | case overBottom 11 | } 12 | 13 | /// The visible area of the `UICollectionView` 14 | /// 15 | var visibleRect: CGRect { 16 | CGRect(origin: contentOffset, size: bounds.size) 17 | } 18 | 19 | /// The maximum offset of the `UICollectionView`, i.e. the bottom and trailing edge 20 | /// 21 | var maxOffset: CGPoint { 22 | CGPoint( 23 | x: contentSize.width - bounds.size.width + safeAreaInsets.right + contentInset.right, 24 | y: contentSize.height - bounds.size.height + safeAreaInsets.bottom + contentInset.bottom 25 | ) 26 | } 27 | 28 | /// The minimum offset of the `UICollectionView`, i.e. the top and leading edge 29 | /// 30 | var minOffset: CGPoint { 31 | CGPoint( 32 | x: -safeAreaInsets.left - contentInset.left, 33 | y: -safeAreaInsets.top - contentInset.top 34 | ) 35 | } 36 | 37 | /// Retrieves the `VerticalCellPosition` of a cell in a `UICollectionView` based on its position 38 | /// 39 | private func verticalCellPosition(for indexPath: IndexPath) -> VerticalCellPosition? { 40 | guard let attributes = layoutAttributesForItem(at: indexPath) else { return nil } 41 | if attributes.frame.minY < visibleRect.minY { 42 | return .overTop 43 | } else if attributes.frame.maxY > visibleRect.maxY { 44 | return .overBottom 45 | } else { 46 | return .visible 47 | } 48 | } 49 | 50 | /// Scrolls to a specified item in a `UICollectionView` such that it becomes entirely visible, 51 | /// along with some additional padding specified per collection view 52 | /// 53 | func verticalScrollItemVisible(at indexPath: IndexPath, 54 | withPadding padding: CGFloat, 55 | animated: Bool) 56 | { 57 | switch verticalCellPosition(for: indexPath) { 58 | case .overTop: 59 | verticalScrollToItem(at: indexPath, forCellPosition: .overTop, padding: padding, animated: animated) 60 | 61 | case .overBottom: 62 | verticalScrollToItem(at: indexPath, forCellPosition: .overBottom, padding: padding, animated: animated) 63 | 64 | default: 65 | return 66 | } 67 | } 68 | 69 | /// Helper function used in `verticalScrollItemVisible` that scrolls to a specified item 70 | /// in a `UICollectionView` depending on its `cellPosition` 71 | /// 72 | func verticalScrollToItem(at indexPath: IndexPath, 73 | forCellPosition cellPosition: VerticalCellPosition, 74 | padding: CGFloat, 75 | animated: Bool) 76 | { 77 | guard let attributes = layoutAttributesForItem(at: indexPath) else { return } 78 | 79 | switch cellPosition { 80 | case .overTop: 81 | var offset = attributes.frame.origin.y - padding 82 | offset = min(max(offset, minOffset.y), maxOffset.y) 83 | setContentOffset(.init(x: 0, y: offset), animated: animated) 84 | 85 | case .overBottom: 86 | var offset = attributes.frame.origin.y - bounds.height + attributes.frame.height + padding 87 | offset = min(max(offset, minOffset.y), maxOffset.y) 88 | setContentOffset(.init(x: 0, y: offset), animated: animated) 89 | 90 | default: 91 | break 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /YUI/Utils/UIScreen+Extensions.swift: -------------------------------------------------------------------------------- 1 | // An even more obfuscated version of https://github.com/kylebshr/ScreenCorners 2 | 3 | import UIKit 4 | 5 | extension UIScreen { 6 | private static let cornerRadiusKey: String = { 7 | let base64Components = [ 8 | "UmFkaXVz", // "Radius" 9 | "Q29ybmVy", // "Corner" 10 | "ZGlzcGxheQ==", // "display" 11 | "Xw==" // "_" 12 | ] 13 | 14 | return base64Components 15 | .map { Data(base64Encoded: $0)! } 16 | .compactMap { String(data: $0, encoding: .utf8) } 17 | .reversed() 18 | .joined() 19 | }() 20 | 21 | public var displayCornerRadius: CGFloat { 22 | let key = Data(Self.cornerRadiusKey.utf8) 23 | .base64EncodedString() 24 | .data(using: .utf8) 25 | .flatMap { Data(base64Encoded: $0) } 26 | .flatMap { String(data: $0, encoding: .utf8) } ?? Self.cornerRadiusKey 27 | 28 | guard let cornerRadius = self.value(forKey: key) as? CGFloat else { 29 | assertionFailure("Failed to detect screen corner radius") 30 | return 0 31 | } 32 | 33 | return cornerRadius 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /YUI/Utils/UIView+Extensions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIView { 4 | /// This computed property converts a view's frame to window coordinates. 5 | /// We do this because we need to know the exact position of a view in the 6 | /// window's coordinate space (i.e. the physical screen), not just its local superview. 7 | /// 8 | var frameInWindow: CGRect? { 9 | superview?.convert(frame, to: nil) 10 | } 11 | 12 | /// A convenience wrapper around `UIView.animate` that adds support for 13 | /// custom timing functions (`CAMediaTimingFunction`). 14 | /// 15 | static func animate( 16 | duration: TimeInterval, 17 | curve: CAMediaTimingFunction? = nil, 18 | options: UIView.AnimationOptions = [], 19 | animations: @escaping () -> Void, 20 | completion: (() -> Void)? = nil 21 | ) { 22 | // Begin CATransaction to set timing curve 23 | CATransaction.begin() 24 | CATransaction.setAnimationTimingFunction(curve) 25 | 26 | UIView.animate( 27 | withDuration: duration, 28 | delay: 0, 29 | options: options, 30 | animations: animations, 31 | completion: { _ in completion?() } 32 | ) 33 | 34 | // Commit the transaction 35 | CATransaction.commit() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /YUI/Utils/ViewControllerIdentifiable.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol ViewControllerIdentifiable { 4 | var stringIdentifier: String { get } 5 | var nameIdentifier: String { get } 6 | } 7 | -------------------------------------------------------------------------------- /YUI/Views/BackButton.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class BackButton: UIButton { 4 | private let buttonSize: CGFloat = 52 5 | private let customTintColor: UIColor 6 | private let blurStyle: UIBlurEffect.Style 7 | var backNavigation: (() -> Void)? 8 | 9 | private lazy var blurView: UIVisualEffectView = { 10 | let blurEffect = UIBlurEffect(style: blurStyle) 11 | let view = UIVisualEffectView(effect: blurEffect) 12 | view.layer.cornerRadius = buttonSize / 2 13 | view.clipsToBounds = true 14 | view.isUserInteractionEnabled = false 15 | return view 16 | }() 17 | 18 | private lazy var chevronImageView: UIImageView = { 19 | let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold) 20 | let image = UIImage(systemName: "arrow.left", withConfiguration: config)? 21 | .withTintColor(customTintColor, renderingMode: .alwaysOriginal) 22 | let imageView = UIImageView(image: image) 23 | imageView.isUserInteractionEnabled = false 24 | return imageView 25 | }() 26 | 27 | init(customTintColor: UIColor = .white, 28 | blurStyle: UIBlurEffect.Style = .systemUltraThinMaterial) 29 | { 30 | self.blurStyle = blurStyle 31 | self.customTintColor = customTintColor 32 | 33 | super.init(frame: .zero) 34 | 35 | setupButton() 36 | } 37 | 38 | required init?(coder: NSCoder) { 39 | fatalError("init(coder:) has not been implemented") 40 | } 41 | 42 | private func setupButton() { 43 | addTarget(self, action: #selector(handleTap), for: .touchUpInside) 44 | 45 | blurView.do { 46 | addSubview($0) 47 | fillWith($0) 48 | } 49 | 50 | chevronImageView.then { 51 | blurView.contentView.addSubview($0) 52 | }.layout { 53 | $0.centerX == blurView.centerXAnchor 54 | $0.centerY == blurView.centerYAnchor 55 | } 56 | 57 | layout { 58 | $0.size == CGSize(width: buttonSize, height: buttonSize) 59 | } 60 | } 61 | 62 | @objc private func handleTap() { 63 | backNavigation?() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /YUI/Views/CacheableImageView/CacheableImageView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class CacheableImageView: UIImageView { 4 | enum ImageDownloadError: Error { 5 | case badURL 6 | case invalidData 7 | } 8 | 9 | private var imageTask: Task? 10 | private var defaultImageCache: ImageCache { 11 | ImageCache.shared 12 | } 13 | 14 | func setImage(from url: URL, 15 | with placeholder: UIImage? = nil, 16 | using imageCache: ImageCache? = nil) 17 | { 18 | // Cancel existing image fetching task 19 | imageTask?.cancel() 20 | 21 | // Set placeholder if provided 22 | setImage(placeholder) 23 | 24 | // Check cache first 25 | let imageCache = imageCache ?? defaultImageCache 26 | if let cachedImage = imageCache.getImage(forKey: url) { 27 | setImage(cachedImage) 28 | return 29 | } 30 | 31 | // If not in cache, download from server 32 | imageTask = Task { 33 | try await setRemoteImageAsync(from: url, saveTo: imageCache) 34 | } 35 | } 36 | 37 | private func setRemoteImageAsync(from url: URL, 38 | saveTo imageCache: ImageCache? = nil) async throws 39 | { 40 | // Download image from remote URL 41 | let request = URLRequest(url: url) 42 | let (data, response) = try await URLSession.shared.data(for: request) 43 | guard let image = UIImage(data: data) else { 44 | throw ImageDownloadError.invalidData 45 | } 46 | 47 | // Save downloaded image to cache 48 | imageCache?.setImage(image, forKey: response.url ?? url) 49 | 50 | // Check if task is cancelled 51 | try Task.checkCancellation() 52 | 53 | // Set as image of the UIImageView 54 | setImage(image) 55 | } 56 | 57 | @MainActor 58 | public func setImage(_ image: UIImage?) { 59 | self.image = image 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /YUI/Views/CacheableImageView/ImageCache.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol ImageCaching { 4 | func getImage(forKey key: URL) -> UIImage? 5 | func setImage(_ image: UIImage, forKey key: URL) 6 | func removeImage(forKey key: URL) 7 | func removeAll() 8 | } 9 | 10 | final class ImageCache { 11 | private let cache = NSCache() 12 | private func cacheKey(from url: URL) -> NSString { 13 | NSString(string: url.absoluteString) 14 | } 15 | 16 | // There is `instance` and `shared` because we want to 17 | // initialize this lazily 18 | private static var instance: ImageCache? 19 | static var shared: ImageCache { 20 | guard let instance else { 21 | let instance = ImageCache() 22 | self.instance = instance 23 | return instance 24 | } 25 | 26 | return instance 27 | } 28 | } 29 | 30 | extension ImageCache: ImageCaching { 31 | public func setImage(_ image: UIImage, forKey key: URL) { 32 | cache.setObject(image, forKey: cacheKey(from: key)) 33 | } 34 | 35 | public func getImage(forKey key: URL) -> UIImage? { 36 | cache.object(forKey: cacheKey(from: key)) 37 | } 38 | 39 | public func removeImage(forKey key: URL) { 40 | cache.removeObject(forKey: cacheKey(from: key)) 41 | } 42 | 43 | public func removeAll() { 44 | cache.removeAllObjects() 45 | } 46 | } 47 | --------------------------------------------------------------------------------