├── .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 |
--------------------------------------------------------------------------------