├── .github └── FUNDING.yml ├── .gitignore ├── .jazzy.yaml ├── .slather.yml ├── .swiftformat ├── .swiftlint.yml ├── .travis.yml ├── ChatLayout.podspec ├── ChatLayout.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ └── ChatLayout.xcscheme ├── ChatLayout ├── Assets │ └── .gitkeep └── Classes │ ├── .gitkeep │ ├── Core │ ├── ChatItemAlignment.swift │ ├── ChatLayoutAttributes.swift │ ├── ChatLayoutDelegate.swift │ ├── ChatLayoutInvalidationContext.swift │ ├── ChatLayoutPositionSnapshot.swift │ ├── ChatLayoutSettings.swift │ ├── CollectionViewChatLayout.swift │ ├── Extensions │ │ ├── CGRect+Extension.swift │ │ ├── IndexPath+Extension.swift │ │ └── RandomAccessCollection+Extension.swift │ └── Model │ │ ├── ChangeItem.swift │ │ ├── ItemKind.swift │ │ ├── ItemModel.swift │ │ ├── ItemPath.swift │ │ ├── ItemSize.swift │ │ ├── LayoutModel.swift │ │ ├── ModelState.swift │ │ ├── SectionModel.swift │ │ └── StateController.swift │ └── Extras │ ├── CellLayoutContainerView.swift │ ├── ContainerCollectionReusableView.swift │ ├── ContainerCollectionViewCell.swift │ ├── ContainerCollectionViewCellDelegate.swift │ ├── EdgeAligningView.swift │ ├── Extensions │ ├── NSLayoutAnchor+Extension.swift │ ├── NSLayoutDimension+Extension.swift │ └── UILayoutPriority+Extension.swift │ ├── ImageMaskedView.swift │ ├── MessageContainerView.swift │ ├── RoundedCornersContainerView.swift │ ├── StaticViewFactory.swift │ └── SwappingContainerView.swift ├── Example ├── .swiftlint.yml ├── ChatLayout.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ └── contents.xcworkspacedata │ └── xcshareddata │ │ ├── xcbaselines │ │ └── 607FACE41AFB9204008FA782.xcbaseline │ │ │ ├── 015C1E82-FCFE-4000-8FA8-0BE2CF731C38.plist │ │ │ ├── 3F1217C8-B24C-4A94-A9AB-1DC2A6F2D2FF.plist │ │ │ ├── 71E5FE30-BEA6-4194-B013-E550EA60539B.plist │ │ │ ├── 7560FB72-C67C-4642-9438-9E8D02DA849C.plist │ │ │ └── Info.plist │ │ └── xcschemes │ │ └── ChatLayout-Example.xcscheme ├── ChatLayout.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── ChatLayout │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── Icon.jpeg │ │ │ ├── Icon120.jpeg │ │ │ ├── Icon152.jpeg │ │ │ ├── Icon167.jpeg │ │ │ ├── Icon180.jpeg │ │ │ ├── Icon60.jpeg │ │ │ ├── Icon76.jpeg │ │ │ └── icon40.jpeg │ │ ├── Bubbles │ │ │ ├── Contents.json │ │ │ ├── bubble_full.imageset │ │ │ │ ├── Contents.json │ │ │ │ ├── bubble_full.png │ │ │ │ ├── bubble_full@2x.png │ │ │ │ └── bubble_full@3x.png │ │ │ └── bubble_full_tail.imageset │ │ │ │ ├── Contents.json │ │ │ │ ├── bubble_full_tail_v2.png │ │ │ │ ├── bubble_full_tail_v2@2x.png │ │ │ │ └── bubble_full_tail_v2@3x.png │ │ ├── Contents.json │ │ ├── Demo Images │ │ │ ├── Contents.json │ │ │ ├── demo1.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── IMG_4156.jpg │ │ │ ├── demo2.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── IMG_5135.jpg │ │ │ ├── demo3.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── IMG_7190.jpg │ │ │ ├── demo4.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── demo4.jpg │ │ │ ├── demo5.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── demo5.jpeg │ │ │ ├── demo6.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── demo6.jpeg │ │ │ ├── demo7.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── demo7.jpeg │ │ │ └── demo8.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── demo8.jpeg │ │ ├── Users │ │ │ ├── Cathal.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── cathal.jpeg │ │ │ ├── Contents.json │ │ │ ├── Eugene.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── eugene.jpeg │ │ │ └── Sasha.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── sasha.jpeg │ │ ├── read_status.imageset │ │ │ ├── Contents.json │ │ │ └── ReFresh Copy 3.pdf │ │ └── sent_status.imageset │ │ │ ├── Contents.json │ │ │ └── ReFresh Copy 3.pdf │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── LaunchScreen.xib │ ├── Chat │ │ ├── Builder │ │ │ └── ChatViewControllerBuilder.swift │ │ ├── Constants.swift │ │ ├── Controller │ │ │ ├── ChatController.swift │ │ │ ├── ChatControllerDelegate.swift │ │ │ ├── DefaultChatController.swift │ │ │ ├── Helpers │ │ │ │ ├── DifferenceKit+Extension.swift │ │ │ │ └── SetActor.swift │ │ │ ├── Image Loader │ │ │ │ ├── CachingImageLoader.swift │ │ │ │ ├── DefaultImageLoader.swift │ │ │ │ ├── ImageLoader.swift │ │ │ │ └── ImageMessageSource.swift │ │ │ └── Keyboard │ │ │ │ ├── KeyboardInfo.swift │ │ │ │ ├── KeyboardListener.swift │ │ │ │ └── KeyboardListenerDelegate.swift │ │ ├── Model │ │ │ ├── Caches.swift │ │ │ ├── Caching │ │ │ │ ├── AsyncKeyValueCaching.swift │ │ │ │ ├── CacheError.swift │ │ │ │ ├── Data │ │ │ │ │ ├── IterativeCache.swift │ │ │ │ │ ├── MemoryDataCache.swift │ │ │ │ │ ├── PersistentDataCache.swift │ │ │ │ │ └── PersistentlyCacheable.swift │ │ │ │ ├── Image │ │ │ │ │ ├── CacheableImageKey.swift │ │ │ │ │ └── ImageForUrlCache.swift │ │ │ │ ├── KeyValueCaching.swift │ │ │ │ └── Metadata │ │ │ │ │ └── MetaDataCache.swift │ │ │ ├── ChatDateFormatter.swift │ │ │ ├── Data Objects │ │ │ │ ├── Cell.swift │ │ │ │ ├── Message.swift │ │ │ │ ├── RawMessage.swift │ │ │ │ ├── Section.swift │ │ │ │ ├── TypingState.swift │ │ │ │ └── User.swift │ │ │ ├── DefaultRandomDataProvider.swift │ │ │ └── TextGenerator.swift │ │ └── View │ │ │ ├── Avatar View │ │ │ ├── AvatarPlaceholderView.swift │ │ │ ├── AvatarView.swift │ │ │ └── AvatarViewController.swift │ │ │ ├── ChatViewController.swift │ │ │ ├── Data Source │ │ │ ├── ChatCollectionDataSource.swift │ │ │ └── DefaultChatCollectionDataSource.swift │ │ │ ├── Date Accessory View │ │ │ ├── DateAccessoryController.swift │ │ │ └── DateAccessoryView.swift │ │ │ ├── Editing Accessory View │ │ │ ├── EditingAccessoryController.swift │ │ │ └── EditingAccessoryView.swift │ │ │ ├── Image View │ │ │ ├── ImageController.swift │ │ │ ├── ImageView.swift │ │ │ └── ImageViewState.swift │ │ │ ├── Other │ │ │ ├── BezierBubbleController.swift │ │ │ ├── BezierMaskedView.swift │ │ │ ├── BubbleController.swift │ │ │ ├── EditNotifier.swift │ │ │ ├── EditNotifierDelegate.swift │ │ │ ├── FullCellContentBubbleController.swift │ │ │ ├── MainContainerView.swift │ │ │ ├── ManualAnimator.swift │ │ │ ├── SwipeNotifier.swift │ │ │ ├── TextBubbleController.swift │ │ │ └── UIView+Extension.swift │ │ │ ├── ReloadDelegate.swift │ │ │ ├── Status View │ │ │ └── StatusView.swift │ │ │ ├── Text Message View │ │ │ ├── TextMessageController.swift │ │ │ └── TextMessageView.swift │ │ │ └── URL View │ │ │ ├── URLController.swift │ │ │ ├── URLSource.swift │ │ │ └── URLView.swift │ ├── Info.plist │ ├── ProcessInfo+Extension.swift │ └── SceneDelegate.swift ├── ChatLayout_Example.entitlements ├── Podfile ├── Podfile.lock └── Tests │ ├── HelpersTests.swift │ ├── Info.plist │ ├── MockCollectionLayout.swift │ ├── MockUICollectionViewUpdateItem.swift │ ├── PerformanceTests.swift │ ├── StateControllerInternalTests.swift │ └── StateControllerProcessUpdatesTests.swift ├── Gemfile ├── Info.plist ├── LICENSE ├── Package.swift ├── README.md ├── _Pods.xcodeproj └── docs ├── Classes ├── CellLayoutContainerView.html ├── ChatLayoutAttributes.html ├── ChatLayoutInvalidationContext.html ├── CollectionViewChatLayout.html ├── ContainerCollectionReusableView.html ├── ContainerCollectionViewCell.html ├── EdgeAligningView.html ├── EdgeAligningView │ └── Edge.html ├── ImageMaskedView.html ├── MessageContainerView.html ├── RoundedCornersContainerView.html ├── SwappingContainerView.html └── SwappingContainerView │ ├── Axis.html │ └── Distribution.html ├── Core.html ├── Enums ├── CellLayoutContainerViewAlignment.html ├── ChatItemAlignment.html ├── ImageMaskedViewTransformation.html ├── InitialAttributesRequestType.html ├── ItemKind.html ├── ItemSize.html └── ItemSize │ └── CaseType.html ├── Extras.html ├── Other Guides.html ├── Protocols ├── ChatLayoutDelegate.html ├── ContainerCollectionViewCellDelegate.html └── StaticViewFactory.html ├── Structs ├── ChatLayoutPositionSnapshot.html ├── ChatLayoutPositionSnapshot │ └── Edge.html ├── ChatLayoutSettings.html └── VoidViewFactory.html ├── badge.svg ├── css ├── highlight.css └── jazzy.css ├── docsets ├── ChatLayout.docset │ └── Contents │ │ ├── Info.plist │ │ └── Resources │ │ ├── Documents │ │ ├── Classes │ │ │ ├── CellLayoutContainerView.html │ │ │ ├── ChatLayoutAttributes.html │ │ │ ├── ChatLayoutInvalidationContext.html │ │ │ ├── CollectionViewChatLayout.html │ │ │ ├── ContainerCollectionReusableView.html │ │ │ ├── ContainerCollectionViewCell.html │ │ │ ├── EdgeAligningView.html │ │ │ ├── EdgeAligningView │ │ │ │ └── Edge.html │ │ │ ├── ImageMaskedView.html │ │ │ ├── MessageContainerView.html │ │ │ ├── RoundedCornersContainerView.html │ │ │ ├── SwappingContainerView.html │ │ │ └── SwappingContainerView │ │ │ │ ├── Axis.html │ │ │ │ └── Distribution.html │ │ ├── Core.html │ │ ├── Enums │ │ │ ├── CellLayoutContainerViewAlignment.html │ │ │ ├── ChatItemAlignment.html │ │ │ ├── ImageMaskedViewTransformation.html │ │ │ ├── InitialAttributesRequestType.html │ │ │ ├── ItemKind.html │ │ │ ├── ItemSize.html │ │ │ └── ItemSize │ │ │ │ └── CaseType.html │ │ ├── Extras.html │ │ ├── Other Guides.html │ │ ├── Protocols │ │ │ ├── ChatLayoutDelegate.html │ │ │ ├── ContainerCollectionViewCellDelegate.html │ │ │ └── StaticViewFactory.html │ │ ├── Structs │ │ │ ├── ChatLayoutPositionSnapshot.html │ │ │ ├── ChatLayoutPositionSnapshot │ │ │ │ └── Edge.html │ │ │ ├── ChatLayoutSettings.html │ │ │ └── VoidViewFactory.html │ │ ├── css │ │ │ ├── highlight.css │ │ │ └── jazzy.css │ │ ├── img │ │ │ ├── carat.png │ │ │ ├── dash.png │ │ │ └── spinner.gif │ │ ├── index.html │ │ ├── js │ │ │ ├── jazzy.js │ │ │ ├── jazzy.search.js │ │ │ ├── jquery.min.js │ │ │ ├── lunr.min.js │ │ │ └── typeahead.jquery.js │ │ ├── readme.html │ │ └── search.json │ │ └── docSet.dsidx └── ChatLayout.tgz ├── img ├── carat.png ├── dash.png └── spinner.gif ├── index.html ├── js ├── jazzy.js ├── jazzy.search.js ├── jquery.min.js ├── lunr.min.js └── typeahead.jquery.js ├── readme.html ├── search.json ├── tests ├── CGRect+Extension.swift.html ├── CellLayoutContainerView.swift.html ├── ChangeItem.swift.html ├── ChatLayoutAttributes.swift.html ├── ChatLayoutDelegate.swift.html ├── ChatLayoutPositionSnapshot.swift.html ├── ChatLayoutSettings.swift.html ├── CollectionViewChatLayout.swift.html ├── ContainerCollectionReusableView.swift.html ├── ContainerCollectionViewCell.swift.html ├── ContainerCollectionViewCellDelegate.swift.html ├── EdgeAligningView.swift.html ├── ImageMaskedView.swift.html ├── IndexPath+Extension.swift.html ├── ItemKind.swift.html ├── ItemModel.swift.html ├── ItemPath.swift.html ├── ItemSize.swift.html ├── LayoutModel.swift.html ├── MessageContainerView.swift.html ├── RoundedCornersContainerView.swift.html ├── SectionModel.swift.html ├── StateController.swift.html ├── StaticViewFactory.swift.html ├── highlight.pack.js ├── index.html ├── list.min.js ├── logo.jpg └── slather.css └── undocumented.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ekazaev 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | Example/Pods 92 | Gemfile.lock 93 | -------------------------------------------------------------------------------- /.jazzy.yaml: -------------------------------------------------------------------------------- 1 | podspec: ChatLayout.podspec 2 | clean: true 3 | author: Evgeny Kazaev 4 | author_url: https://github.com/ekazaev 5 | output: docs 6 | documentation: ./*.md 7 | theme: fullwidth 8 | 9 | custom_categories: 10 | - name: Core 11 | children: 12 | - CollectionViewChatLayout 13 | - ChatLayoutDelegate 14 | - ChatLayoutAttributes 15 | - ChatLayoutSettings 16 | - ChatLayoutPositionSnapshot 17 | - ChatLayoutInvalidationContext 18 | - ItemKind 19 | - ItemSize 20 | - ChatItemAlignment 21 | - InitialAttributesRequestType 22 | 23 | - name: Extras 24 | children: 25 | - ContainerCollectionViewCell 26 | - ContainerCollectionViewCellDelegate 27 | - ContainerCollectionReusableView 28 | - MessageContainerView 29 | - CellLayoutContainerView 30 | - CellLayoutContainerViewAlignment 31 | - EdgeAligningView 32 | - SwappingContainerView 33 | - ImageMaskedView 34 | - ImageMaskedViewTransformation 35 | - RoundedCornersContainerView 36 | - StaticViewFactory 37 | - VoidViewFactory 38 | -------------------------------------------------------------------------------- /.slather.yml: -------------------------------------------------------------------------------- 1 | xcodeproj: ./Example/ChatLayout.xcodeproj 2 | workspace: ./Example/ChatLayout.xcworkspace 3 | scheme: ChatLayout-Example 4 | source_directory: ./ChatLayout/Classes 5 | binary_basename: ChatLayout 6 | ignore: 7 | - ./Example/* -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --allman false 2 | --binarygrouping none 3 | --closingparen balanced 4 | --commas inline 5 | --conflictmarkers reject 6 | --decimalgrouping none 7 | --elseposition same-line 8 | --guardelse same-line 9 | --empty void 10 | --exponentcase lowercase 11 | --exponentgrouping disabled 12 | --fractiongrouping disabled 13 | --fragment false 14 | --header "\nChatLayout\n{file}\nhttps://github.com/ekazaev/ChatLayout\n\nCreated by Eugene Kazaev in 2020-{year}.\nDistributed under the MIT license.\n\nBecome a sponsor:\nhttps://github.com/sponsors/ekazaev\n" 15 | --hexgrouping none 16 | --hexliteralcase uppercase 17 | --ifdef no-indent 18 | --importgrouping alphabetized 19 | --indent 4 20 | --indentcase false 21 | --linebreaks lf 22 | --maxwidth none 23 | --nospaceoperators ...,..<,..> 24 | --octalgrouping none 25 | --operatorfunc spaced 26 | --patternlet hoist 27 | --selfrequired 28 | --semicolons inline 29 | --stripunusedargs closure-only 30 | --tabwidth unspecified 31 | --trimwhitespace always 32 | --wraparguments preserve 33 | --wrapcollections preserve 34 | --xcodeindentation disabled 35 | --modifierorder public,override 36 | --nevertrailing map, flatMap, compactMap 37 | --funcattributes prev-line 38 | --typeattributes prev-line 39 | --disable wrapMultilineStatementBraces, preferKeyPath, trailingclosures, preferForLoop,conditionalAssignment 40 | --enable isEmpty,wrapConditionalBodies,noExplicitOwnership,wrapEnumCases,wrapSwitchCases,sortSwitchCases,wrapAttributes 41 | --exclude Pods,docs,Example/Pods,RecyclerView/Classes/Utils/Stolen 42 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: # rule identifiers to exclude from running 2 | - colon 3 | - comma 4 | - control_statement 5 | - nesting 6 | opt_in_rules: # some rules are only opt-in 7 | - empty_count 8 | # Find all the available rules by running: 9 | # swiftlint rules 10 | included: # paths to include during linting. `--path` is ignored if present. 11 | - Example 12 | - RouteComposer 13 | excluded: # paths to ignore during linting. Takes precedence over `included`. 14 | - Carthage 15 | - Pods 16 | - Source/ExcludedFolder 17 | - Source/ExcludedFile.swift 18 | - Source/*/ExcludedFile.swift # Exclude files with a wildcard 19 | 20 | # configurable rules can be customized from this configuration file 21 | # binary rules can set their severity level 22 | force_cast: warning # implicitly 23 | force_try: 24 | severity: warning # explicitly 25 | # rules that have both warning and error levels, can set just the warning level 26 | # implicitly 27 | line_length: 180 28 | 29 | # they can set both implicitly with an array 30 | type_body_length: 31 | - 400 # warning 32 | - 500 # error 33 | 34 | function_body_length: 35 | - 150 # warning 36 | - 200 # error 37 | 38 | cyclomatic_complexity: 39 | - 15 # warning 40 | - 25 # error 41 | 42 | 43 | # or they can set both explicitly 44 | file_length: 45 | warning: 500 46 | error: 1200 47 | 48 | # naming rules can set warnings/errors for min_length and max_length 49 | # additionally they can set excluded names 50 | type_name: 51 | min_length: 4 # only warning 52 | max_length: # warning and error 53 | warning: 40 54 | error: 50 55 | excluded: iPhone # excluded via string 56 | 57 | identifier_name: 58 | min_length: # only min_length 59 | error: 3 # only error 60 | excluded: # excluded via string array 61 | - id 62 | - vc 63 | - key 64 | - url 65 | - URL 66 | - GlobalAPIKey 67 | 68 | function_parameter_count: 6 69 | 70 | large_tuple: 71 | - 4 72 | - 10 73 | reporter: "xcode" # reporter type (xcode, json, csv, checkstyle, junit, html, emoji, sonarqube) -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # references: 2 | # * https://www.objc.io/issues/6-build-tools/travis-ci/ 3 | # * https://github.com/supermarin/xcpretty#usage 4 | 5 | osx_image: xcode12.2 6 | language: swift 7 | cache: cocoapods, slather 8 | podfile: Example/Podfile 9 | before_install: 10 | - gem install slather 11 | - gem install cocoapods # Since Travis is not always on latest version 12 | - pod install --project-directory=Example 13 | script: 14 | - set -o pipefail && xcodebuild test -enableCodeCoverage YES -workspace Example/ChatLayout.xcworkspace -scheme ChatLayout-Example -sdk iphonesimulator -derivedDataPath ${TRAVIS_BUILD_DIR}/chatLayoutDerivedData -destination 'platform=iOS Simulator,name=iPhone 11,OS=13.5' ONLY_ACTIVE_ARCH=YES | xcpretty 15 | - pod lib lint --allow-warnings 16 | after_success: 17 | - slather coverage -t -b ${TRAVIS_BUILD_DIR}/chatLayoutDerivedData --cobertura-xml --output-directory ${TRAVIS_BUILD_DIR}/tests --arch x86_64 --verbose 18 | - bash <(curl -s https://codecov.io/bash) -f ${TRAVIS_BUILD_DIR}/tests/cobertura.xml -X coveragepy -X gcov -X xcode -t 3dc048e4-cc93-4a01-9423-4f516c9e1885 19 | -------------------------------------------------------------------------------- /ChatLayout.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'ChatLayout' 3 | s.version = '2.0.13' 4 | s.summary = 'Chat UI Library. It uses custom UICollectionViewLayout to provide you full control over the presentation.' 5 | s.swift_version = '5.10' 6 | 7 | s.description = <<-DESC 8 | ChatLayout is a Chat UI Library. It uses custom UICollectionViewLayout to provide you full control over the 9 | presentation as well as all the tools available in UICollectionView. It supports dynamic cells and 10 | supplementary view sizes. 11 | DESC 12 | 13 | s.homepage = 'https://github.com/ekazaev/ChatLayout' 14 | s.license = { :type => 'MIT', :file => 'LICENSE' } 15 | s.author = { 'Eugene Kazaev' => 'eugene.kazaev@gmail.com' } 16 | s.source = { :git => 'https://github.com/ekazaev/ChatLayout.git', :tag => s.version.to_s } 17 | 18 | s.ios.deployment_target = '12.0' 19 | 20 | s.default_subspec = "Ultimate" 21 | 22 | s.subspec "Ultimate" do |complete| 23 | complete.dependency "ChatLayout/Core" 24 | complete.dependency "ChatLayout/Extras" 25 | end 26 | 27 | s.subspec "Core" do |core| 28 | core.source_files = 'ChatLayout/Classes/Core/**/*' 29 | end 30 | 31 | s.subspec "Extras" do |extras| 32 | extras.source_files = 'ChatLayout/Classes/Extras/**/*' 33 | extras.dependency "ChatLayout/Core" 34 | end 35 | 36 | s.frameworks = 'UIKit' 37 | end 38 | -------------------------------------------------------------------------------- /ChatLayout.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ChatLayout.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ChatLayout.xcodeproj/xcshareddata/xcschemes/ChatLayout.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /ChatLayout/Assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/ChatLayout/Assets/.gitkeep -------------------------------------------------------------------------------- /ChatLayout/Classes/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/ChatLayout/Classes/.gitkeep -------------------------------------------------------------------------------- /ChatLayout/Classes/Core/ChatItemAlignment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ChatItemAlignment.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | /// Represent item alignment in collection view layout 17 | public enum ChatItemAlignment: Hashable { 18 | /// Should be aligned at the leading edge of the layout. That includes all the additional content offsets. 19 | case leading 20 | 21 | /// Should be aligned at the center of the layout. 22 | case center 23 | 24 | /// Should be aligned at the trailing edge of the layout. 25 | case trailing 26 | 27 | /// Should be aligned using the full width of the available content width. 28 | case fullWidth 29 | } 30 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Core/ChatLayoutAttributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ChatLayoutAttributes.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | /// Custom implementation of `UICollectionViewLayoutAttributes` 17 | public final class ChatLayoutAttributes: UICollectionViewLayoutAttributes { 18 | /// Alignment of the current item. Can be changed within `UICollectionViewCell.preferredLayoutAttributesFitting(...)` 19 | public var alignment: ChatItemAlignment = .fullWidth 20 | 21 | /// Inter item spacing. Can be changed within `UICollectionViewCell.preferredLayoutAttributesFitting(...)` 22 | public var interItemSpacing: CGFloat = 0 23 | 24 | /// `CollectionViewChatLayout`s additional insets setup using `ChatLayoutSettings`. Added for convenience. 25 | public internal(set) var additionalInsets: UIEdgeInsets = .zero 26 | 27 | /// `UICollectionView`s frame size. Added for convenience. 28 | public internal(set) var viewSize: CGSize = .zero 29 | 30 | /// `UICollectionView`s adjusted content insets. Added for convenience. 31 | public internal(set) var adjustedContentInsets: UIEdgeInsets = .zero 32 | 33 | /// `CollectionViewChatLayout`s visible bounds size excluding `adjustedContentInsets`. Added for convenience. 34 | public internal(set) var visibleBoundsSize: CGSize = .zero 35 | 36 | /// `CollectionViewChatLayout`s visible bounds size excluding `adjustedContentInsets` and `additionalInsets`. Added for convenience. 37 | public internal(set) var layoutFrame: CGRect = .zero 38 | 39 | #if DEBUG 40 | var id: UUID? 41 | #endif 42 | 43 | convenience init(kind: ItemKind, indexPath: IndexPath = IndexPath(item: 0, section: 0)) { 44 | switch kind { 45 | case .cell: 46 | self.init(forCellWith: indexPath) 47 | case .header: 48 | self.init(forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, with: indexPath) 49 | case .footer: 50 | self.init(forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, with: indexPath) 51 | } 52 | } 53 | 54 | /// Returns an exact copy of `ChatLayoutAttributes`. 55 | public override func copy(with zone: NSZone? = nil) -> Any { 56 | let copy = super.copy(with: zone) as! ChatLayoutAttributes 57 | copy.viewSize = viewSize 58 | copy.alignment = alignment 59 | copy.interItemSpacing = interItemSpacing 60 | copy.layoutFrame = layoutFrame 61 | copy.additionalInsets = additionalInsets 62 | copy.visibleBoundsSize = visibleBoundsSize 63 | copy.adjustedContentInsets = adjustedContentInsets 64 | #if DEBUG 65 | copy.id = id 66 | #endif 67 | return copy 68 | } 69 | 70 | /// Returns a Boolean value indicating whether two `ChatLayoutAttributes` are considered equal. 71 | public override func isEqual(_ object: Any?) -> Bool { 72 | super.isEqual(object) 73 | && alignment == (object as? ChatLayoutAttributes)?.alignment 74 | && interItemSpacing == (object as? ChatLayoutAttributes)?.interItemSpacing 75 | } 76 | 77 | /// `ItemKind` represented by this attributes object. 78 | public var kind: ItemKind { 79 | switch (representedElementCategory, representedElementKind) { 80 | case (.cell, nil): 81 | .cell 82 | case (.supplementaryView, .some(UICollectionView.elementKindSectionHeader)): 83 | .header 84 | case (.supplementaryView, .some(UICollectionView.elementKindSectionFooter)): 85 | .footer 86 | default: 87 | preconditionFailure("Unsupported element kind.") 88 | } 89 | } 90 | 91 | func typedCopy() -> ChatLayoutAttributes { 92 | guard let typedCopy = copy() as? ChatLayoutAttributes else { 93 | fatalError("Internal inconsistency.") 94 | } 95 | return typedCopy 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Core/ChatLayoutInvalidationContext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ChatLayoutInvalidationContext.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | /// Custom implementation of `UICollectionViewLayoutInvalidationContext` 17 | public final class ChatLayoutInvalidationContext: UICollectionViewLayoutInvalidationContext { 18 | /// Indicates whether to recompute the positions and sizes of the items based on the current 19 | /// collection view and delegate layout metrics. 20 | public var invalidateLayoutMetrics = true 21 | } 22 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Core/ChatLayoutPositionSnapshot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ChatLayoutPositionSnapshot.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | /// Represents content offset position expressed by the specific item and it offset from the top or bottom edge. 17 | public struct ChatLayoutPositionSnapshot: Hashable { 18 | /// Represents the edge. 19 | public enum Edge: Hashable { 20 | /// Top edge of the `UICollectionView` 21 | case top 22 | 23 | /// Bottom edge of the `UICollectionView` 24 | case bottom 25 | } 26 | 27 | /// Item's `IndexPath` 28 | public var indexPath: IndexPath 29 | 30 | /// Kind of item at the `indexPath` 31 | public var kind: ItemKind 32 | 33 | /// The edge of the offset. 34 | public var edge: Edge 35 | 36 | /// The offset from the `edge`. 37 | public var offset: CGFloat 38 | 39 | /// Constructor 40 | /// - Parameters: 41 | /// - indexPath: Item's `IndexPath` 42 | /// - edge: The edge of the offset. 43 | /// - offset: The offset from the `edge`. 44 | /// - kind: Kind of item at the `indexPath` 45 | public init(indexPath: IndexPath, 46 | kind: ItemKind, 47 | edge: Edge, 48 | offset: CGFloat = 0) { 49 | self.indexPath = indexPath 50 | self.edge = edge 51 | self.offset = offset 52 | self.kind = kind 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Core/ChatLayoutSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ChatLayoutSettings.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | /// `CollectionViewChatLayout` settings. 17 | public struct ChatLayoutSettings: Equatable { 18 | /// Estimated item size for `CollectionViewChatLayout`. This value will be used as the initial size of the item and the final size 19 | /// will be calculated using `UICollectionViewCell.preferredLayoutAttributesFitting(...)`. 20 | public var estimatedItemSize: CGSize? 21 | 22 | /// Spacing between the items in the section. 23 | public var interItemSpacing: CGFloat = 0 24 | 25 | /// Spacing between the sections. 26 | public var interSectionSpacing: CGFloat = 0 27 | 28 | /// Additional insets for the `CollectionViewChatLayout` content. 29 | public var additionalInsets: UIEdgeInsets = .zero 30 | } 31 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Core/Extensions/CGRect+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // CGRect+Extension.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | extension CGRect { 17 | var higherPoint: CGPoint { 18 | origin 19 | } 20 | 21 | var lowerPoint: CGPoint { 22 | CGPoint(x: origin.x + size.width, y: origin.y + size.height) 23 | } 24 | 25 | var centerPoint: CGPoint { 26 | CGPoint(x: origin.x + size.width / 2, y: origin.y + size.height / 2) 27 | } 28 | 29 | @inline(__always) 30 | mutating func offsettingBy(dx: CGFloat, dy: CGFloat) { 31 | origin.x += dx 32 | origin.y += dy 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Core/Extensions/IndexPath+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // IndexPath+Extension.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | extension IndexPath { 16 | var itemPath: ItemPath { 17 | ItemPath(for: self) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Core/Extensions/RandomAccessCollection+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // RandomAccessCollection+Extension.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | extension RandomAccessCollection where Index == Int { 16 | func binarySearch(predicate: (Element) -> ComparisonResult) -> Index? { 17 | var lowerBound = startIndex 18 | var upperBound = endIndex 19 | 20 | while lowerBound < upperBound { 21 | let midIndex = lowerBound &+ (upperBound &- lowerBound) / 2 22 | let result = predicate(self[midIndex]) 23 | if result == .orderedSame { 24 | return midIndex 25 | } else if result == .orderedAscending { 26 | lowerBound = midIndex &+ 1 27 | } else { 28 | upperBound = midIndex 29 | } 30 | } 31 | return nil 32 | } 33 | 34 | func binarySearchRange(predicate: (Element) -> ComparisonResult) -> [Element] { 35 | func leftMostSearch(lowerBound: Index, upperBound: Index) -> Index? { 36 | var lowerBound = lowerBound 37 | var upperBound = upperBound 38 | 39 | while lowerBound < upperBound { 40 | let midIndex = (lowerBound &+ upperBound) / 2 41 | if predicate(self[midIndex]) == .orderedAscending { 42 | lowerBound = midIndex &+ 1 43 | } else { 44 | upperBound = midIndex 45 | } 46 | } 47 | if predicate(self[lowerBound]) == .orderedSame { 48 | return lowerBound 49 | } else { 50 | return nil 51 | } 52 | } 53 | 54 | func rightMostSearch(lowerBound: Index, upperBound: Index) -> Index? { 55 | var lowerBound = lowerBound 56 | var upperBound = upperBound 57 | 58 | while lowerBound < upperBound { 59 | let midIndex = (lowerBound &+ upperBound &+ 1) / 2 60 | if predicate(self[midIndex]) == .orderedDescending { 61 | upperBound = midIndex &- 1 62 | } else { 63 | lowerBound = midIndex 64 | } 65 | } 66 | if predicate(self[lowerBound]) == .orderedSame { 67 | return lowerBound 68 | } else { 69 | return nil 70 | } 71 | } 72 | 73 | guard !isEmpty, 74 | let lowerBound = leftMostSearch(lowerBound: startIndex, upperBound: endIndex - 1), 75 | let upperBound = rightMostSearch(lowerBound: startIndex, upperBound: endIndex - 1) else { 76 | return [] 77 | } 78 | 79 | return Array(self[lowerBound...upperBound]) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Core/Model/ChangeItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ChangeItem.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | /// Internal replacement for `UICollectionViewUpdateItem`. 17 | enum ChangeItem: Equatable { 18 | /// Delete section at `sectionIndex` 19 | case sectionDelete(sectionIndex: Int) 20 | 21 | /// Delete item at `itemIndexPath` 22 | case itemDelete(itemIndexPath: IndexPath) 23 | 24 | /// Insert section at `sectionIndex` 25 | case sectionInsert(sectionIndex: Int) 26 | 27 | /// Insert item at `itemIndexPath` 28 | case itemInsert(itemIndexPath: IndexPath) 29 | 30 | /// Reload section at `sectionIndex` 31 | case sectionReload(sectionIndex: Int) 32 | 33 | /// Reload item at `itemIndexPath` 34 | case itemReload(itemIndexPath: IndexPath) 35 | 36 | /// Reconfigure item at `itemIndexPath` 37 | case itemReconfigure(itemIndexPath: IndexPath) 38 | 39 | /// Move section from `initialSectionIndex` to `finalSectionIndex` 40 | case sectionMove(initialSectionIndex: Int, finalSectionIndex: Int) 41 | 42 | /// Move item from `initialItemIndexPath` to `finalItemIndexPath` 43 | case itemMove(initialItemIndexPath: IndexPath, finalItemIndexPath: IndexPath) 44 | 45 | init?(with updateItem: UICollectionViewUpdateItem) { 46 | let updateAction = updateItem.updateAction 47 | let indexPathBeforeUpdate = updateItem.indexPathBeforeUpdate 48 | let indexPathAfterUpdate = updateItem.indexPathAfterUpdate 49 | switch updateAction { 50 | case .none: 51 | return nil 52 | case .move: 53 | guard let indexPathBeforeUpdate, 54 | let indexPathAfterUpdate else { 55 | assertionFailure("`indexPathBeforeUpdate` and `indexPathAfterUpdate` cannot be `nil` for a `.move` update action.") 56 | return nil 57 | } 58 | if indexPathBeforeUpdate.item == NSNotFound, indexPathAfterUpdate.item == NSNotFound { 59 | self = .sectionMove(initialSectionIndex: indexPathBeforeUpdate.section, 60 | finalSectionIndex: indexPathAfterUpdate.section) 61 | } else { 62 | self = .itemMove(initialItemIndexPath: indexPathBeforeUpdate, 63 | finalItemIndexPath: indexPathAfterUpdate) 64 | } 65 | case .insert: 66 | guard let indexPath = indexPathAfterUpdate else { 67 | assertionFailure("`indexPathAfterUpdate` cannot be `nil` for an `.insert` update action.") 68 | return nil 69 | } 70 | if indexPath.item == NSNotFound { 71 | self = .sectionInsert(sectionIndex: indexPath.section) 72 | } else { 73 | self = .itemInsert(itemIndexPath: indexPath) 74 | } 75 | case .delete: 76 | guard let indexPath = indexPathBeforeUpdate else { 77 | assertionFailure("`indexPathBeforeUpdate` cannot be `nil` for a `.delete` update action.") 78 | return nil 79 | } 80 | if indexPath.item == NSNotFound { 81 | self = .sectionDelete(sectionIndex: indexPath.section) 82 | } else { 83 | self = .itemDelete(itemIndexPath: indexPath) 84 | } 85 | case .reload: 86 | guard let indexPath = indexPathBeforeUpdate else { 87 | assertionFailure("`indexPathAfterUpdate` cannot be `nil` for a `.reload` update action.") 88 | return nil 89 | } 90 | 91 | if indexPath.item == NSNotFound { 92 | self = .sectionReload(sectionIndex: indexPath.section) 93 | } else { 94 | self = .itemReload(itemIndexPath: indexPath) 95 | } 96 | @unknown default: 97 | return nil 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Core/Model/ItemKind.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ItemKind.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | /// Type of the item supported by `CollectionViewChatLayout` 17 | public enum ItemKind: CaseIterable, Hashable { 18 | /// Header item 19 | case header 20 | 21 | /// Cell item 22 | case cell 23 | 24 | /// Footer item 25 | case footer 26 | 27 | init(_ elementKind: String) { 28 | switch elementKind { 29 | case UICollectionView.elementKindSectionHeader: 30 | self = .header 31 | case UICollectionView.elementKindSectionFooter: 32 | self = .footer 33 | default: 34 | preconditionFailure("Unsupported supplementary view kind.") 35 | } 36 | } 37 | 38 | /// Returns: `true` if this `ItemKind` is equal to `ItemKind.header` or `ItemKind.footer` 39 | public var isSupplementaryItem: Bool { 40 | switch self { 41 | case .cell: 42 | false 43 | case .footer, 44 | .header: 45 | true 46 | } 47 | } 48 | 49 | var supplementaryElementStringType: String { 50 | switch self { 51 | case .cell: 52 | preconditionFailure("Cell type is not a supplementary view.") 53 | case .header: 54 | UICollectionView.elementKindSectionHeader 55 | case .footer: 56 | UICollectionView.elementKindSectionFooter 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Core/Model/ItemModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ItemModel.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | struct ItemModel { 17 | struct Configuration { 18 | let alignment: ChatItemAlignment 19 | 20 | let preferredSize: CGSize 21 | 22 | let calculatedSize: CGSize? 23 | 24 | let interItemSpacing: CGFloat 25 | } 26 | 27 | let id: UUID 28 | 29 | var preferredSize: CGSize 30 | 31 | var offsetY: CGFloat = .zero 32 | 33 | var calculatedSize: CGSize? 34 | 35 | var calculatedOnce: Bool = false 36 | 37 | var alignment: ChatItemAlignment 38 | 39 | var interItemSpacing: CGFloat 40 | 41 | var size: CGSize { 42 | guard let calculatedSize else { 43 | return preferredSize 44 | } 45 | 46 | return calculatedSize 47 | } 48 | 49 | var frame: CGRect { 50 | CGRect(origin: CGPoint(x: 0, y: offsetY), size: size) 51 | } 52 | 53 | init(id: UUID = UUID(), with configuration: Configuration) { 54 | self.id = id 55 | alignment = configuration.alignment 56 | preferredSize = configuration.preferredSize 57 | interItemSpacing = configuration.interItemSpacing 58 | calculatedSize = configuration.calculatedSize 59 | calculatedOnce = configuration.calculatedSize != nil 60 | } 61 | 62 | // We are just resetting `calculatedSize` if needed as the actual size will be found in 63 | // `invalidationContext(forPreferredLayoutAttributes:, withOriginalAttributes:)`. 64 | // It is important for the rotation to keep previous frame size. 65 | mutating func resetSize() { 66 | guard let calculatedSize else { 67 | return 68 | } 69 | self.calculatedSize = nil 70 | preferredSize = calculatedSize 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Core/Model/ItemPath.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ItemPath.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | /// Represents the location of an item in a section. 16 | /// 17 | /// Initializing a `ItemPath` is measurably faster than initializing an `IndexPath`. 18 | /// On an iPhone X, compiled with -Os optimizations, it's about 35x faster to initialize this struct 19 | /// compared to an `IndexPath`. 20 | struct ItemPath: Hashable { 21 | let section: Int 22 | 23 | let item: Int 24 | 25 | var indexPath: IndexPath { 26 | IndexPath(item: item, section: section) 27 | } 28 | 29 | init(item: Int, section: Int) { 30 | self.section = section 31 | self.item = item 32 | } 33 | 34 | init(for indexPath: IndexPath) { 35 | section = indexPath.section 36 | item = indexPath.item 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Core/Model/ItemSize.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ItemSize.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | /// Represents desired item size. 17 | public enum ItemSize: Hashable { 18 | /// Item size should be fully calculated by the `CollectionViewChatLayout`. 19 | /// Initial estimated size will be taken from `ChatLayoutSettings`. 20 | case auto 21 | 22 | /// Item size should be fully calculated by the `CollectionViewChatLayout`. 23 | /// Initial estimated size should be taken from the value provided. 24 | case estimated(CGSize) 25 | 26 | /// Item size should be exactly equal to the value provided. 27 | case exact(CGSize) 28 | 29 | /// Represents current item size case type. 30 | public enum CaseType: Hashable, CaseIterable { 31 | /// Represents `ItemSize.auto` 32 | case auto 33 | /// Represents `ItemSize.estimated` 34 | case estimated 35 | /// Represents `ItemSize.exact` 36 | case exact 37 | } 38 | 39 | /// Returns current item size case type. 40 | public var caseType: CaseType { 41 | switch self { 42 | case .auto: 43 | .auto 44 | case .estimated: 45 | .estimated 46 | case .exact: 47 | .exact 48 | } 49 | } 50 | 51 | public func hash(into hasher: inout Hasher) { 52 | hasher.combine(caseType) 53 | switch self { 54 | case .auto: 55 | break 56 | case let .estimated(size): 57 | hasher.combine(size.width) 58 | hasher.combine(size.height) 59 | case let .exact(size): 60 | hasher.combine(size.width) 61 | hasher.combine(size.height) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Core/Model/ModelState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ModelState.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | enum ModelState: Hashable, CaseIterable { 16 | case beforeUpdate 17 | case afterUpdate 18 | } 19 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Extras/ContainerCollectionReusableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ContainerCollectionReusableView.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | /// A container `UICollectionReusableView` that constraints its contained view to its margins. 17 | public final class ContainerCollectionReusableView: UICollectionReusableView { 18 | /// Default reuse identifier is set with the class name. 19 | public static var reuseIdentifier: String { 20 | String(describing: self) 21 | } 22 | 23 | /// Contained view. 24 | public lazy var customView = CustomView(frame: bounds) 25 | 26 | /// An instance of `ContainerCollectionViewCellDelegate` 27 | public weak var delegate: ContainerCollectionViewCellDelegate? 28 | 29 | /// Initializes and returns a newly allocated view object with the specified frame rectangle. 30 | /// - Parameter frame: The frame rectangle for the view, measured in points. The origin of the frame is relative 31 | /// to the superview in which you plan to add it. 32 | override init(frame: CGRect) { 33 | super.init(frame: frame) 34 | setupSubviews() 35 | } 36 | 37 | /// This constructor is unavailable. 38 | @available(*, unavailable, message: "Use init(frame:) instead") 39 | public required init?(coder aDecoder: NSCoder) { 40 | fatalError("init(coder:) has not been implemented") 41 | } 42 | 43 | /// Performs any clean up necessary to prepare the view for use again. 44 | public override func prepareForReuse() { 45 | super.prepareForReuse() 46 | delegate?.prepareForReuse() 47 | } 48 | 49 | /// Gives the cell a chance to modify the attributes provided by the layout object. 50 | /// - Parameter layoutAttributes: The attributes provided by the layout object. These attributes represent the values that the layout intends to apply to the cell. 51 | /// - Returns: Modified `UICollectionViewLayoutAttributes` 52 | public override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { 53 | guard let chatLayoutAttributes = layoutAttributes as? ChatLayoutAttributes else { 54 | return super.preferredLayoutAttributesFitting(layoutAttributes) 55 | } 56 | delegate?.apply(chatLayoutAttributes) 57 | 58 | let resultingLayoutAttributes: ChatLayoutAttributes 59 | if let preferredLayoutAttributes = delegate?.preferredLayoutAttributesFitting(chatLayoutAttributes) { 60 | resultingLayoutAttributes = preferredLayoutAttributes 61 | } else if let chatLayoutAttributes = super.preferredLayoutAttributesFitting(chatLayoutAttributes) as? ChatLayoutAttributes { 62 | delegate?.modifyPreferredLayoutAttributesFitting(chatLayoutAttributes) 63 | resultingLayoutAttributes = chatLayoutAttributes 64 | } else { 65 | resultingLayoutAttributes = chatLayoutAttributes 66 | } 67 | return resultingLayoutAttributes 68 | } 69 | 70 | /// Applies the specified layout attributes to the view. 71 | /// - Parameter layoutAttributes: The layout attributes to apply. 72 | public override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) { 73 | guard let chatLayoutAttributes = layoutAttributes as? ChatLayoutAttributes else { 74 | return 75 | } 76 | super.apply(layoutAttributes) 77 | delegate?.apply(chatLayoutAttributes) 78 | } 79 | 80 | private func setupSubviews() { 81 | addSubview(customView) 82 | insetsLayoutMarginsFromSafeArea = false 83 | layoutMargins = .zero 84 | 85 | customView.translatesAutoresizingMaskIntoConstraints = false 86 | NSLayoutConstraint.activate([ 87 | customView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), 88 | customView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), 89 | customView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 90 | customView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor) 91 | ]) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Extras/ContainerCollectionViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ContainerCollectionViewCell.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | /// A container `UICollectionViewCell` that constraints its contained view to its margins. 17 | public final class ContainerCollectionViewCell: UICollectionViewCell { 18 | /// Default reuse identifier is set with the class name. 19 | public static var reuseIdentifier: String { 20 | String(describing: self) 21 | } 22 | 23 | /// Contained view. 24 | public lazy var customView = CustomView(frame: bounds) 25 | 26 | /// An instance of `ContainerCollectionViewCellDelegate` 27 | public weak var delegate: ContainerCollectionViewCellDelegate? 28 | 29 | /// Initializes and returns a newly allocated view object with the specified frame rectangle. 30 | /// - Parameter frame: The frame rectangle for the view, measured in points. The origin of the frame is relative 31 | /// to the superview in which you plan to add it. 32 | override init(frame: CGRect) { 33 | super.init(frame: frame) 34 | setupSubviews() 35 | } 36 | 37 | /// This constructor is unavailable. 38 | @available(*, unavailable, message: "Use init(reuseIdentifier:) instead.") 39 | public required init?(coder aDecoder: NSCoder) { 40 | fatalError("init(coder:) has not been implemented.") 41 | } 42 | 43 | /// Performs any clean up necessary to prepare the view for use again. 44 | public override func prepareForReuse() { 45 | super.prepareForReuse() 46 | delegate?.prepareForReuse() 47 | } 48 | 49 | /// Gives the cell a chance to modify the attributes provided by the layout object. 50 | /// - Parameter layoutAttributes: The attributes provided by the layout object. These attributes represent the values that the layout intends to apply to the cell. 51 | /// - Returns: Modified `UICollectionViewLayoutAttributes` 52 | public override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { 53 | guard let chatLayoutAttributes = layoutAttributes as? ChatLayoutAttributes else { 54 | return super.preferredLayoutAttributesFitting(layoutAttributes) 55 | } 56 | delegate?.apply(chatLayoutAttributes) 57 | let resultingLayoutAttributes: ChatLayoutAttributes 58 | if let preferredLayoutAttributes = delegate?.preferredLayoutAttributesFitting(chatLayoutAttributes) { 59 | resultingLayoutAttributes = preferredLayoutAttributes 60 | } else if let chatLayoutAttributes = super.preferredLayoutAttributesFitting(chatLayoutAttributes) as? ChatLayoutAttributes { 61 | delegate?.modifyPreferredLayoutAttributesFitting(chatLayoutAttributes) 62 | resultingLayoutAttributes = chatLayoutAttributes 63 | } else { 64 | resultingLayoutAttributes = chatLayoutAttributes 65 | } 66 | return resultingLayoutAttributes 67 | } 68 | 69 | /// Applies the specified layout attributes to the view. 70 | /// - Parameter layoutAttributes: The layout attributes to apply. 71 | public override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) { 72 | guard let chatLayoutAttributes = layoutAttributes as? ChatLayoutAttributes else { 73 | return 74 | } 75 | super.apply(layoutAttributes) 76 | delegate?.apply(chatLayoutAttributes) 77 | } 78 | 79 | private func setupSubviews() { 80 | contentView.addSubview(customView) 81 | insetsLayoutMarginsFromSafeArea = false 82 | layoutMargins = .zero 83 | 84 | contentView.insetsLayoutMarginsFromSafeArea = false 85 | contentView.layoutMargins = .zero 86 | 87 | customView.translatesAutoresizingMaskIntoConstraints = false 88 | NSLayoutConstraint.activate([ 89 | customView.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), 90 | customView.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor), 91 | customView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), 92 | customView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor) 93 | ]) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Extras/ContainerCollectionViewCellDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ContainerCollectionViewCellDelegate.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | /// A delegate of `ContainerCollectionViewCell`/`ContainerCollectionReusableView` should implement this methods if 17 | /// it is required to participate in containers lifecycle. 18 | public protocol ContainerCollectionViewCellDelegate: AnyObject { 19 | /// Perform any clean up necessary to prepare the view for use again. 20 | func prepareForReuse() 21 | 22 | /// Allows to override the call of `ContainerCollectionViewCell`/`ContainerCollectionReusableView` 23 | /// `UICollectionReusableView.preferredLayoutAttributesFitting(...)` and make the layout calculations. 24 | /// 25 | /// **NB**: You must override it to avoid unnecessary autolayout calculations if you are providing exact cell size 26 | /// in `ChatLayoutDelegate.sizeForItem(...)` and return `layoutAttributes` without modifications. 27 | /// - Parameter layoutAttributes: `ChatLayoutAttributes` provided by `CollectionViewChatLayout` 28 | /// - Returns: Modified `ChatLayoutAttributes` on nil if `UICollectionReusableView.preferredLayoutAttributesFitting(...)` 29 | /// should be called instead. 30 | func preferredLayoutAttributesFitting(_ layoutAttributes: ChatLayoutAttributes) -> ChatLayoutAttributes? 31 | 32 | /// Allows to additionally modify `ChatLayoutAttributes` after the `UICollectionReusableView.preferredLayoutAttributesFitting(...)` 33 | /// call. 34 | /// - Parameter layoutAttributes: `ChatLayoutAttributes` provided by `CollectionViewChatLayout`. 35 | /// - Returns: Modified `ChatLayoutAttributes` 36 | func modifyPreferredLayoutAttributesFitting(_ layoutAttributes: ChatLayoutAttributes) 37 | 38 | /// Apply the specified layout attributes to the view. 39 | /// Keep in mind that this method can be called multiple times. 40 | /// - Parameter layoutAttributes: `ChatLayoutAttributes` provided by `CollectionViewChatLayout`. 41 | func apply(_ layoutAttributes: ChatLayoutAttributes) 42 | } 43 | 44 | /// Default extension to make the methods optional for implementation in the successor 45 | public extension ContainerCollectionViewCellDelegate { 46 | /// Default implementation does nothing. 47 | func prepareForReuse() {} 48 | 49 | /// Default implementation returns: `nil`. 50 | func preferredLayoutAttributesFitting(_ layoutAttributes: ChatLayoutAttributes) -> ChatLayoutAttributes? { 51 | nil 52 | } 53 | 54 | /// Default implementation does nothing. 55 | func modifyPreferredLayoutAttributesFitting(_ layoutAttributes: ChatLayoutAttributes) {} 56 | 57 | /// Default implementation does nothing. 58 | func apply(_ layoutAttributes: ChatLayoutAttributes) {} 59 | } 60 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Extras/Extensions/NSLayoutAnchor+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // NSLayoutAnchor+Extension.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | extension NSLayoutAnchor { 17 | @objc 18 | func constraint(equalTo anchor: NSLayoutAnchor, 19 | constant c: CGFloat = 0, 20 | priority: UILayoutPriority) -> NSLayoutConstraint { 21 | let constraint = constraint(equalTo: anchor, constant: c) 22 | constraint.priority = priority 23 | return constraint 24 | } 25 | 26 | @objc 27 | func constraint(greaterThanOrEqualTo anchor: NSLayoutAnchor, 28 | constant c: CGFloat = 0, 29 | priority: UILayoutPriority) -> NSLayoutConstraint { 30 | let constraint = constraint(greaterThanOrEqualTo: anchor, constant: c) 31 | constraint.priority = priority 32 | return constraint 33 | } 34 | 35 | @objc 36 | func constraint(lessThanOrEqualTo anchor: NSLayoutAnchor, 37 | constant c: CGFloat = 0, 38 | priority: UILayoutPriority) -> NSLayoutConstraint { 39 | let constraint = constraint(lessThanOrEqualTo: anchor, constant: c) 40 | constraint.priority = priority 41 | return constraint 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Extras/Extensions/NSLayoutDimension+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // NSLayoutDimension+Extension.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | extension NSLayoutDimension { 17 | @objc 18 | func constraint(equalTo anchor: NSLayoutDimension, 19 | multiplier m: CGFloat = 1, 20 | constant c: CGFloat = 0, 21 | priority: UILayoutPriority) -> NSLayoutConstraint { 22 | let constraint = constraint(equalTo: anchor, multiplier: m, constant: c) 23 | constraint.priority = priority 24 | return constraint 25 | } 26 | 27 | @objc 28 | func constraint(greaterThanOrEqualTo anchor: NSLayoutDimension, 29 | multiplier m: CGFloat = 1, 30 | constant c: CGFloat = 0, 31 | priority: UILayoutPriority) -> NSLayoutConstraint { 32 | let constraint = constraint(greaterThanOrEqualTo: anchor, multiplier: m, constant: c) 33 | constraint.priority = priority 34 | return constraint 35 | } 36 | 37 | @objc 38 | func constraint(lessThanOrEqualTo anchor: NSLayoutDimension, 39 | multiplier m: CGFloat = 1, 40 | constant c: CGFloat = 0, 41 | priority: UILayoutPriority) -> NSLayoutConstraint { 42 | let constraint = constraint(lessThanOrEqualTo: anchor, multiplier: m, constant: c) 43 | constraint.priority = priority 44 | return constraint 45 | } 46 | 47 | @objc 48 | func constraint(equalToConstant c: CGFloat, 49 | priority: UILayoutPriority) -> NSLayoutConstraint { 50 | let constraint = constraint(equalToConstant: c) 51 | constraint.priority = priority 52 | return constraint 53 | } 54 | 55 | @objc 56 | func constraint(greaterThanOrEqualToConstant c: CGFloat, 57 | priority: UILayoutPriority) -> NSLayoutConstraint { 58 | let constraint = constraint(greaterThanOrEqualToConstant: c) 59 | constraint.priority = priority 60 | return constraint 61 | } 62 | 63 | @objc 64 | func constraint(lessThanOrEqualToConstant c: CGFloat, 65 | priority: UILayoutPriority) -> NSLayoutConstraint { 66 | let constraint = constraint(lessThanOrEqualToConstant: c) 67 | constraint.priority = priority 68 | return constraint 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Extras/Extensions/UILayoutPriority+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // UILayoutPriority+Extension.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | extension UILayoutPriority { 17 | static let almostRequired = UILayoutPriority(rawValue: UILayoutPriority.required.rawValue - 1) 18 | } 19 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Extras/ImageMaskedView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ImageMaskedView.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | /// A transformation to apply to the `ImageMaskedView.maskingImage` 17 | public enum ImageMaskedViewTransformation { 18 | /// Keep image as it is. 19 | case asIs 20 | 21 | /// Flip image vertically. 22 | case flippedVertically 23 | } 24 | 25 | /// A container view that masks its contained view with an image provided. 26 | public final class ImageMaskedView: UIView { 27 | /// Contained view. 28 | public lazy var customView = CustomView(frame: bounds) 29 | 30 | /// An Image to be used as a mask for the `customView`. 31 | public var maskingImage: UIImage? { 32 | didSet { 33 | setupMask() 34 | } 35 | } 36 | 37 | /// A transformation to apply to the `maskingImage`. 38 | public var maskTransformation: ImageMaskedViewTransformation = .asIs { 39 | didSet { 40 | guard oldValue != maskTransformation else { 41 | return 42 | } 43 | updateMask() 44 | } 45 | } 46 | 47 | private lazy var imageView = UIImageView(frame: bounds) 48 | 49 | /// Initializes and returns a newly allocated view object with the specified frame rectangle. 50 | /// - Parameter frame: The frame rectangle for the view, measured in points. The origin of the frame is relative 51 | /// to the superview in which you plan to add it. 52 | public override init(frame: CGRect) { 53 | super.init(frame: frame) 54 | setupSubviews() 55 | } 56 | 57 | /// Returns an object initialized from data in a given unarchiver. 58 | /// - Parameter coder: An unarchiver object. 59 | public required init?(coder: NSCoder) { 60 | super.init(coder: coder) 61 | setupSubviews() 62 | } 63 | 64 | private func setupSubviews() { 65 | layoutMargins = .zero 66 | translatesAutoresizingMaskIntoConstraints = false 67 | insetsLayoutMarginsFromSafeArea = false 68 | 69 | addSubview(customView) 70 | customView.translatesAutoresizingMaskIntoConstraints = false 71 | NSLayoutConstraint.activate([ 72 | customView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), 73 | customView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), 74 | customView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 75 | customView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor) 76 | ]) 77 | } 78 | 79 | private func setupMask() { 80 | guard let bubbleImage = maskingImage else { 81 | imageView.image = nil 82 | mask = nil 83 | return 84 | } 85 | 86 | imageView.image = bubbleImage 87 | mask = imageView 88 | updateMask() 89 | } 90 | 91 | private func updateMask() { 92 | UIView.performWithoutAnimation { 93 | let multiplier = effectiveUserInterfaceLayoutDirection == .leftToRight ? 1 : -1 94 | switch maskTransformation { 95 | case .flippedVertically: 96 | imageView.transform = CGAffineTransform(scaleX: CGFloat(multiplier * -1), y: 1) 97 | case .asIs: 98 | imageView.transform = CGAffineTransform(scaleX: CGFloat(multiplier * 1), y: 1) 99 | } 100 | } 101 | } 102 | 103 | /// The frame rectangle, which describes the view’s location and size in its superview’s coordinate system. 104 | public override final var frame: CGRect { 105 | didSet { 106 | imageView.frame = bounds 107 | } 108 | } 109 | 110 | /// The bounds rectangle, which describes the view’s location and size in its own coordinate system. 111 | public override final var bounds: CGRect { 112 | didSet { 113 | imageView.frame = bounds 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Extras/MessageContainerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // MessageContainerView.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | /// A container view that helps to layout the message view and its accessory 17 | public final class MessageContainerView: UIView { 18 | private lazy var stackView = UIStackView(frame: bounds) 19 | 20 | /// An accessory view. 21 | public lazy var accessoryView: AccessoryViewFactory.View? = AccessoryViewFactory.buildView(within: bounds) 22 | 23 | /// Main view. 24 | public var customView: MainView { 25 | internalContentView.customView 26 | } 27 | 28 | /// An alignment of the contained views within the `MessageContainerView`, 29 | public var alignment: ChatItemAlignment = .fullWidth { 30 | didSet { 31 | switch alignment { 32 | case .leading: 33 | internalContentView.flexibleEdges = [.trailing] 34 | case .trailing: 35 | internalContentView.flexibleEdges = [.leading] 36 | case .center: 37 | internalContentView.flexibleEdges = [.leading, .trailing] 38 | case .fullWidth: 39 | internalContentView.flexibleEdges = [] 40 | } 41 | } 42 | } 43 | 44 | private lazy var internalContentView = EdgeAligningView(frame: bounds) 45 | 46 | /// Initializes and returns a newly allocated view object with the specified frame rectangle. 47 | /// - Parameter frame: The frame rectangle for the view, measured in points. The origin of the frame is relative 48 | /// to the superview in which you plan to add it. 49 | public override init(frame: CGRect) { 50 | super.init(frame: frame) 51 | setupSubviews() 52 | } 53 | 54 | /// Returns an object initialized from data in a given unarchiver. 55 | /// - Parameter coder: An unarchiver object. 56 | public required init?(coder: NSCoder) { 57 | super.init(coder: coder) 58 | setupSubviews() 59 | } 60 | 61 | private func setupSubviews() { 62 | translatesAutoresizingMaskIntoConstraints = false 63 | insetsLayoutMarginsFromSafeArea = false 64 | layoutMargins = .zero 65 | addSubview(stackView) 66 | 67 | stackView.translatesAutoresizingMaskIntoConstraints = false 68 | stackView.axis = .horizontal 69 | stackView.spacing = .zero 70 | 71 | NSLayoutConstraint.activate([ 72 | stackView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), 73 | stackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), 74 | stackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 75 | stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor) 76 | ]) 77 | 78 | if let accessoryView { 79 | stackView.addArrangedSubview(accessoryView) 80 | accessoryView.translatesAutoresizingMaskIntoConstraints = false 81 | } 82 | 83 | internalContentView.translatesAutoresizingMaskIntoConstraints = false 84 | stackView.addArrangedSubview(internalContentView) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Extras/RoundedCornersContainerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // RoundedCornersContainerView.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | /// A container view that keeps its `CustomView` masked with the corner radius provided. 17 | public final class RoundedCornersContainerView: UIView { 18 | /// Corner radius. If not provided then the half of the current view height will be used. 19 | public var cornerRadius: CGFloat? 20 | 21 | /// Contained view. 22 | public lazy var customView = CustomView(frame: bounds) 23 | 24 | /// Initializes and returns a newly allocated view object with the specified frame rectangle. 25 | /// - Parameter frame: The frame rectangle for the view, measured in points. The origin of the frame is relative 26 | /// to the superview in which you plan to add it. 27 | public override init(frame: CGRect) { 28 | super.init(frame: frame) 29 | setupSubviews() 30 | } 31 | 32 | /// Returns an object initialized from data in a given unarchiver. 33 | /// - Parameter coder: An unarchiver object. 34 | public required init?(coder: NSCoder) { 35 | super.init(coder: coder) 36 | setupSubviews() 37 | } 38 | 39 | private func setupSubviews() { 40 | addSubview(customView) 41 | translatesAutoresizingMaskIntoConstraints = false 42 | insetsLayoutMarginsFromSafeArea = false 43 | layoutMargins = .zero 44 | 45 | customView.translatesAutoresizingMaskIntoConstraints = false 46 | NSLayoutConstraint.activate([ 47 | customView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), 48 | customView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), 49 | customView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 50 | customView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor) 51 | ]) 52 | } 53 | 54 | /// Lays out subviews. 55 | public override func layoutSubviews() { 56 | super.layoutSubviews() 57 | layer.masksToBounds = false 58 | layer.cornerRadius = cornerRadius ?? frame.height / 2 59 | clipsToBounds = true 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /ChatLayout/Classes/Extras/StaticViewFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // StaticViewFactory.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | /// A factory that creates optional contained `UIView`s should conform to this protocol. 17 | public protocol StaticViewFactory { 18 | /// A type of the view to build. 19 | associatedtype View: UIView 20 | 21 | /// Factory method that will be called by the corresponding container `UIView` 22 | /// - Parameter bounds: A bounds rect of the container. 23 | /// - Returns: Build `UIView` instance. 24 | static func buildView(within bounds: CGRect) -> View? 25 | } 26 | 27 | /// Default extension build the `UIView` using its default constructor. 28 | public extension StaticViewFactory where Self: UIView { 29 | static func buildView(within bounds: CGRect) -> Self? { 30 | Self(frame: bounds) 31 | } 32 | } 33 | 34 | /// Use this factory to specify that this view should not be build and should be equal to nil within the container. 35 | public struct VoidViewFactory: StaticViewFactory { 36 | /// Nil view placeholder type. 37 | public final class VoidView: UIView { 38 | @available(*, unavailable, message: "This view can not be instantiated.") 39 | public required init?(coder aDecoder: NSCoder) { 40 | fatalError("This view can not be instantiated.") 41 | } 42 | 43 | @available(*, unavailable, message: "This view can not be instantiated.") 44 | public override init(frame: CGRect) { 45 | fatalError("This view can not be instantiated.") 46 | } 47 | 48 | @available(*, unavailable, message: "This view can not be instantiated.") 49 | public init() { 50 | fatalError("This view can not be instantiated.") 51 | } 52 | } 53 | 54 | public static func buildView(within bounds: CGRect) -> VoidView? { 55 | nil 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Example/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | ./../.swiftlint.yml -------------------------------------------------------------------------------- /Example/ChatLayout.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/ChatLayout.xcodeproj/xcshareddata/xcbaselines/607FACE41AFB9204008FA782.xcbaseline/015C1E82-FCFE-4000-8FA8-0BE2CF731C38.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | PerformanceTests 8 | 9 | testBinarySearchPerformance() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.174530 15 | baselineIntegrationDisplayName 16 | Local Baseline 17 | 18 | 19 | testBinarySearchRangePerformance() 20 | 21 | com.apple.XCTPerformanceMetric_WallClockTime 22 | 23 | baselineAverage 24 | 0.261330 25 | baselineIntegrationDisplayName 26 | Local Baseline 27 | 28 | 29 | testDeletePerformance() 30 | 31 | com.apple.XCTPerformanceMetric_WallClockTime 32 | 33 | baselineAverage 34 | 0.099875 35 | baselineIntegrationDisplayName 36 | Local Baseline 37 | 38 | 39 | testInsertionPerformance() 40 | 41 | com.apple.XCTPerformanceMetric_WallClockTime 42 | 43 | baselineAverage 44 | 0.052917 45 | baselineIntegrationDisplayName 46 | Local Baseline 47 | 48 | 49 | testItemUpdatePerformance() 50 | 51 | com.apple.XCTPerformanceMetric_WallClockTime 52 | 53 | baselineAverage 54 | 0.013000 55 | baselineIntegrationDisplayName 56 | Local Baseline 57 | 58 | 59 | testLayoutAttributesForElementsPerformance() 60 | 61 | com.apple.XCTPerformanceMetric_WallClockTime 62 | 63 | baselineAverage 64 | 0.148351 65 | baselineIntegrationDisplayName 66 | Local Baseline 67 | 68 | 69 | testReloadPerformance() 70 | 71 | com.apple.XCTPerformanceMetric_WallClockTime 72 | 73 | baselineAverage 74 | 0.009473 75 | baselineIntegrationDisplayName 76 | Local Baseline 77 | 78 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /Example/ChatLayout.xcodeproj/xcshareddata/xcbaselines/607FACE41AFB9204008FA782.xcbaseline/3F1217C8-B24C-4A94-A9AB-1DC2A6F2D2FF.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | PerformanceTests 8 | 9 | testBinarySearchPerformance() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.415907 15 | baselineIntegrationDisplayName 16 | Local Baseline 17 | 18 | 19 | testBinarySearchRangePerformance() 20 | 21 | com.apple.XCTPerformanceMetric_WallClockTime 22 | 23 | baselineAverage 24 | 0.591400 25 | baselineIntegrationDisplayName 26 | Local Baseline 27 | 28 | 29 | testDeletePerformance() 30 | 31 | com.apple.XCTPerformanceMetric_WallClockTime 32 | 33 | baselineAverage 34 | 0.170193 35 | baselineIntegrationDisplayName 36 | Local Baseline 37 | 38 | 39 | testInsertionPerformance() 40 | 41 | com.apple.XCTPerformanceMetric_WallClockTime 42 | 43 | baselineAverage 44 | 0.090893 45 | baselineIntegrationDisplayName 46 | Local Baseline 47 | 48 | 49 | testItemUpdatePerformance() 50 | 51 | com.apple.XCTPerformanceMetric_WallClockTime 52 | 53 | baselineAverage 54 | 0.019746 55 | baselineIntegrationDisplayName 56 | Local Baseline 57 | 58 | 59 | testLayoutAttributesForElementsPerformance() 60 | 61 | com.apple.XCTPerformanceMetric_WallClockTime 62 | 63 | baselineAverage 64 | 0.270313 65 | baselineIntegrationDisplayName 66 | Local Baseline 67 | 68 | 69 | testReloadPerformance() 70 | 71 | com.apple.XCTPerformanceMetric_WallClockTime 72 | 73 | baselineAverage 74 | 0.014233 75 | baselineIntegrationDisplayName 76 | Local Baseline 77 | 78 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /Example/ChatLayout.xcodeproj/xcshareddata/xcbaselines/607FACE41AFB9204008FA782.xcbaseline/7560FB72-C67C-4642-9438-9E8D02DA849C.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | StateControllerProcessUpdatesTests 8 | 9 | testDeletePerformance() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.239000 15 | baselineIntegrationDisplayName 16 | Local Baseline 17 | 18 | 19 | testInsertionPerformance() 20 | 21 | com.apple.XCTPerformanceMetric_WallClockTime 22 | 23 | baselineAverage 24 | 0.122240 25 | baselineIntegrationDisplayName 26 | Local Baseline 27 | 28 | 29 | testItemUpdatePerformance() 30 | 31 | com.apple.XCTPerformanceMetric_WallClockTime 32 | 33 | baselineAverage 34 | 0.190000 35 | baselineIntegrationDisplayName 36 | Local Baseline 37 | 38 | 39 | testObjectPerformance() 40 | 41 | com.apple.XCTPerformanceMetric_WallClockTime 42 | 43 | baselineAverage 44 | 0.056500 45 | baselineIntegrationDisplayName 46 | Local Baseline 47 | 48 | 49 | testReloadPerformance() 50 | 51 | com.apple.XCTPerformanceMetric_WallClockTime 52 | 53 | baselineAverage 54 | 0.098000 55 | baselineIntegrationDisplayName 56 | Local Baseline 57 | 58 | 59 | testStructPerformance() 60 | 61 | com.apple.XCTPerformanceMetric_WallClockTime 62 | 63 | baselineAverage 64 | 0.057100 65 | baselineIntegrationDisplayName 66 | Local Baseline 67 | 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /Example/ChatLayout.xcodeproj/xcshareddata/xcbaselines/607FACE41AFB9204008FA782.xcbaseline/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | runDestinationsByUUID 6 | 7 | 015C1E82-FCFE-4000-8FA8-0BE2CF731C38 8 | 9 | targetArchitecture 10 | arm64 11 | targetDevice 12 | 13 | modelCode 14 | iPhone15,2 15 | platformIdentifier 16 | com.apple.platform.iphoneos 17 | 18 | 19 | 3F1217C8-B24C-4A94-A9AB-1DC2A6F2D2FF 20 | 21 | targetArchitecture 22 | arm64 23 | targetDevice 24 | 25 | modelCode 26 | iPad8,11 27 | platformIdentifier 28 | com.apple.platform.iphoneos 29 | 30 | 31 | 71E5FE30-BEA6-4194-B013-E550EA60539B 32 | 33 | targetArchitecture 34 | arm64 35 | targetDevice 36 | 37 | modelCode 38 | iPhone9,3 39 | platformIdentifier 40 | com.apple.platform.iphoneos 41 | 42 | 43 | 7560FB72-C67C-4642-9438-9E8D02DA849C 44 | 45 | localComputer 46 | 47 | busSpeedInMHz 48 | 400 49 | cpuCount 50 | 1 51 | cpuKind 52 | Quad-Core Intel Core i7 53 | cpuSpeedInMHz 54 | 2800 55 | logicalCPUCoresPerPackage 56 | 8 57 | modelCode 58 | MacBookPro15,2 59 | physicalCPUCoresPerPackage 60 | 4 61 | platformIdentifier 62 | com.apple.platform.macosx 63 | 64 | targetArchitecture 65 | x86_64 66 | targetDevice 67 | 68 | modelCode 69 | iPhone12,1 70 | platformIdentifier 71 | com.apple.platform.iphonesimulator 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /Example/ChatLayout.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Example/ChatLayout.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/ChatLayout/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // AppDelegate.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import UIKit 14 | 15 | @main 16 | class AppDelegate: UIResponder, UIApplicationDelegate { 17 | var window: UIWindow? 18 | 19 | func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 20 | guard !ProcessInfo.isRunningTests else { 21 | return false 22 | } 23 | if #available(iOS 13.0, *) { 24 | } else { 25 | let window = UIWindow() 26 | 27 | let chatViewController = ChatViewControllerBuilder().build() 28 | let viewController = UINavigationController(rootViewController: chatViewController) 29 | 30 | self.window = window 31 | window.rootViewController = viewController 32 | window.makeKeyAndVisible() 33 | } 34 | return true 35 | } 36 | 37 | // MARK: UISceneSession Lifecycle 38 | 39 | @available(iOS 13.0, *) 40 | func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration { 41 | // Called when a new scene session is being created. 42 | // Use this method to select a configuration to create the new scene with. 43 | UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 44 | } 45 | 46 | @available(iOS 13.0, *) 47 | func application(_: UIApplication, didDiscardSceneSessions _: Set) { 48 | // Called when the user discards a scene session. 49 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 50 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon40.jpeg", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "Icon60.jpeg", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "idiom" : "iphone", 17 | "scale" : "2x", 18 | "size" : "29x29" 19 | }, 20 | { 21 | "idiom" : "iphone", 22 | "scale" : "3x", 23 | "size" : "29x29" 24 | }, 25 | { 26 | "idiom" : "iphone", 27 | "scale" : "2x", 28 | "size" : "40x40" 29 | }, 30 | { 31 | "idiom" : "iphone", 32 | "scale" : "3x", 33 | "size" : "40x40" 34 | }, 35 | { 36 | "filename" : "Icon120.jpeg", 37 | "idiom" : "iphone", 38 | "scale" : "2x", 39 | "size" : "60x60" 40 | }, 41 | { 42 | "filename" : "Icon180.jpeg", 43 | "idiom" : "iphone", 44 | "scale" : "3x", 45 | "size" : "60x60" 46 | }, 47 | { 48 | "idiom" : "ipad", 49 | "scale" : "1x", 50 | "size" : "20x20" 51 | }, 52 | { 53 | "idiom" : "ipad", 54 | "scale" : "2x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "idiom" : "ipad", 59 | "scale" : "1x", 60 | "size" : "29x29" 61 | }, 62 | { 63 | "idiom" : "ipad", 64 | "scale" : "2x", 65 | "size" : "29x29" 66 | }, 67 | { 68 | "idiom" : "ipad", 69 | "scale" : "1x", 70 | "size" : "40x40" 71 | }, 72 | { 73 | "idiom" : "ipad", 74 | "scale" : "2x", 75 | "size" : "40x40" 76 | }, 77 | { 78 | "filename" : "Icon76.jpeg", 79 | "idiom" : "ipad", 80 | "scale" : "1x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "filename" : "Icon152.jpeg", 85 | "idiom" : "ipad", 86 | "scale" : "2x", 87 | "size" : "76x76" 88 | }, 89 | { 90 | "filename" : "Icon167.jpeg", 91 | "idiom" : "ipad", 92 | "scale" : "2x", 93 | "size" : "83.5x83.5" 94 | }, 95 | { 96 | "filename" : "Icon.jpeg", 97 | "idiom" : "ios-marketing", 98 | "scale" : "1x", 99 | "size" : "1024x1024" 100 | } 101 | ], 102 | "info" : { 103 | "author" : "xcode", 104 | "version" : 1 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/AppIcon.appiconset/Icon.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/AppIcon.appiconset/Icon.jpeg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/AppIcon.appiconset/Icon120.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/AppIcon.appiconset/Icon120.jpeg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/AppIcon.appiconset/Icon152.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/AppIcon.appiconset/Icon152.jpeg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/AppIcon.appiconset/Icon167.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/AppIcon.appiconset/Icon167.jpeg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/AppIcon.appiconset/Icon180.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/AppIcon.appiconset/Icon180.jpeg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/AppIcon.appiconset/Icon60.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/AppIcon.appiconset/Icon60.jpeg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/AppIcon.appiconset/Icon76.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/AppIcon.appiconset/Icon76.jpeg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/AppIcon.appiconset/icon40.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/AppIcon.appiconset/icon40.jpeg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Bubbles/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Bubbles/bubble_full.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "bubble_full.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "bubble_full@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "bubble_full@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Bubbles/bubble_full.imageset/bubble_full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/Bubbles/bubble_full.imageset/bubble_full.png -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Bubbles/bubble_full.imageset/bubble_full@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/Bubbles/bubble_full.imageset/bubble_full@2x.png -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Bubbles/bubble_full.imageset/bubble_full@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/Bubbles/bubble_full.imageset/bubble_full@3x.png -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Bubbles/bubble_full_tail.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "bubble_full_tail_v2.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "bubble_full_tail_v2@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "bubble_full_tail_v2@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | }, 23 | "properties" : { 24 | "template-rendering-intent" : "template" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Bubbles/bubble_full_tail.imageset/bubble_full_tail_v2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/Bubbles/bubble_full_tail.imageset/bubble_full_tail_v2.png -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Bubbles/bubble_full_tail.imageset/bubble_full_tail_v2@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/Bubbles/bubble_full_tail.imageset/bubble_full_tail_v2@2x.png -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Bubbles/bubble_full_tail.imageset/bubble_full_tail_v2@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/Bubbles/bubble_full_tail.imageset/bubble_full_tail_v2@3x.png -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Demo Images/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Demo Images/demo1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "IMG_4156.jpg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Demo Images/demo1.imageset/IMG_4156.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/Demo Images/demo1.imageset/IMG_4156.jpg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Demo Images/demo2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "IMG_5135.jpg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Demo Images/demo2.imageset/IMG_5135.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/Demo Images/demo2.imageset/IMG_5135.jpg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Demo Images/demo3.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "IMG_7190.jpg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Demo Images/demo3.imageset/IMG_7190.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/Demo Images/demo3.imageset/IMG_7190.jpg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Demo Images/demo4.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "demo4.jpg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Demo Images/demo4.imageset/demo4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/Demo Images/demo4.imageset/demo4.jpg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Demo Images/demo5.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "demo5.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Demo Images/demo5.imageset/demo5.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/Demo Images/demo5.imageset/demo5.jpeg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Demo Images/demo6.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "demo6.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Demo Images/demo6.imageset/demo6.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/Demo Images/demo6.imageset/demo6.jpeg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Demo Images/demo7.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "demo7.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Demo Images/demo7.imageset/demo7.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/Demo Images/demo7.imageset/demo7.jpeg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Demo Images/demo8.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "demo8.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Demo Images/demo8.imageset/demo8.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/Demo Images/demo8.imageset/demo8.jpeg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Users/Cathal.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "cathal.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Users/Cathal.imageset/cathal.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/Users/Cathal.imageset/cathal.jpeg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Users/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Users/Eugene.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "eugene.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Users/Eugene.imageset/eugene.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/Users/Eugene.imageset/eugene.jpeg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Users/Sasha.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "sasha.jpeg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/Users/Sasha.imageset/sasha.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/Users/Sasha.imageset/sasha.jpeg -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/read_status.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "ReFresh Copy 3.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/read_status.imageset/ReFresh Copy 3.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/read_status.imageset/ReFresh Copy 3.pdf -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/sent_status.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "ReFresh Copy 3.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Example/ChatLayout/Assets.xcassets/sent_status.imageset/ReFresh Copy 3.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/Example/ChatLayout/Assets.xcassets/sent_status.imageset/ReFresh Copy 3.pdf -------------------------------------------------------------------------------- /Example/ChatLayout/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 | -------------------------------------------------------------------------------- /Example/ChatLayout/Base.lproj/LaunchScreen.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 24 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Builder/ChatViewControllerBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ChatViewControllerBuilder.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | struct ChatViewControllerBuilder { 17 | func build() -> UIViewController { 18 | let dataProvider = DefaultRandomDataProvider(receiverId: 0, usersIds: [1, 2, 3]) 19 | let messageController = DefaultChatController(dataProvider: dataProvider, userId: 0) 20 | 21 | let editNotifier = EditNotifier() 22 | let swipeNotifier = SwipeNotifier() 23 | let dataSource = DefaultChatCollectionDataSource(editNotifier: editNotifier, 24 | swipeNotifier: swipeNotifier, 25 | reloadDelegate: messageController, 26 | editingDelegate: messageController) 27 | 28 | dataProvider.delegate = messageController 29 | 30 | let messageViewController = ChatViewController(chatController: messageController, dataSource: dataSource, editNotifier: editNotifier, swipeNotifier: swipeNotifier) 31 | messageController.delegate = messageViewController 32 | 33 | return messageViewController 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // Constants.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | struct Constants { 17 | static let tailSize: CGFloat = 5 18 | 19 | static let maxWidth: CGFloat = 0.65 20 | 21 | private init() {} 22 | } 23 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Controller/ChatController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ChatController.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | protocol ChatController { 16 | func loadInitialMessages(completion: @escaping ([Section]) -> Void) 17 | 18 | func loadPreviousMessages(completion: @escaping ([Section]) -> Void) 19 | 20 | func sendMessage(_ data: Message.Data, completion: @escaping ([Section]) -> Void) 21 | } 22 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Controller/ChatControllerDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ChatControllerDelegate.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | protocol ChatControllerDelegate: AnyObject { 16 | func update(with sections: [Section], requiresIsolatedProcess: Bool) 17 | } 18 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Controller/Image Loader/CachingImageLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // CachingImageLoader.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | public struct CachingImageLoader: ImageLoader where C.CachingKey == CacheableImageKey, C.Entity == UIImage { 17 | private let cache: C 18 | 19 | private let loader: ImageLoader 20 | 21 | public init(cache: C, loader: ImageLoader) { 22 | self.cache = cache 23 | self.loader = loader 24 | } 25 | 26 | public func loadImage(from url: URL, 27 | completion: @escaping (Result) -> Void) { 28 | let imageKey = CacheableImageKey(url: url) 29 | cache.getEntity(for: imageKey, completion: { result in 30 | guard case .failure = result else { 31 | completion(result) 32 | return 33 | } 34 | loader.loadImage(from: url, completion: { result in 35 | switch result { 36 | case let .success(image): 37 | try? cache.store(entity: image, for: imageKey) 38 | completion(.success(image)) 39 | case .failure: 40 | completion(result) 41 | } 42 | }) 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Controller/Image Loader/DefaultImageLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // DefaultImageLoader.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | public struct DefaultImageLoader: ImageLoader { 17 | public enum ImageError: Error { 18 | case unknown 19 | case corruptedData 20 | } 21 | 22 | public init() {} 23 | 24 | public func loadImage(from url: URL, completion: @escaping (Result) -> Void) { 25 | let request = URLRequest(url: url, cachePolicy: .reloadIgnoringCacheData) 26 | let session = URLSession(configuration: URLSessionConfiguration.default, delegate: nil, delegateQueue: nil) 27 | let sessionDataTask = session.dataTask(with: request, completionHandler: { (data: Data?, _: URLResponse?, error: Error?) in 28 | DispatchQueue.global(qos: .utility).async { 29 | guard let imageData = data else { 30 | DispatchQueue.main.async { 31 | guard let error else { 32 | completion(.failure(ImageError.unknown)) 33 | return 34 | } 35 | completion(.failure(error)) 36 | } 37 | return 38 | } 39 | guard let image = UIImage(data: imageData) else { 40 | DispatchQueue.main.async { 41 | completion(.failure(ImageError.corruptedData)) 42 | } 43 | return 44 | } 45 | DispatchQueue.main.async { 46 | completion(.success(image)) 47 | } 48 | } 49 | }) 50 | sessionDataTask.resume() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Controller/Image Loader/ImageLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ImageLoader.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | public protocol ImageLoader { 17 | func loadImage(from url: URL, completion: @escaping (Result) -> Void) 18 | } 19 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Controller/Image Loader/ImageMessageSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ImageMessageSource.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | enum ImageMessageSource: Hashable { 17 | case image(UIImage) 18 | case imageURL(URL) 19 | } 20 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Controller/Keyboard/KeyboardInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // KeyboardInfo.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | struct KeyboardInfo: Equatable { 17 | let animationDuration: Double 18 | 19 | let animationCurve: UIView.AnimationCurve 20 | 21 | let frameBegin: CGRect 22 | 23 | let frameEnd: CGRect 24 | 25 | let isLocal: Bool 26 | 27 | init?(_ notification: Notification) { 28 | guard let userInfo: NSDictionary = notification.userInfo as NSDictionary?, 29 | let keyboardAnimationCurve = (userInfo.object(forKey: UIResponder.keyboardAnimationCurveUserInfoKey) as? NSValue) as? Int, 30 | let keyboardAnimationDuration = (userInfo.object(forKey: UIResponder.keyboardAnimationDurationUserInfoKey) as? NSValue) as? Double, 31 | let keyboardIsLocal = (userInfo.object(forKey: UIResponder.keyboardIsLocalUserInfoKey) as? NSValue) as? Bool, 32 | let keyboardFrameBegin = (userInfo.object(forKey: UIResponder.keyboardFrameBeginUserInfoKey) as? NSValue)?.cgRectValue, 33 | let keyboardFrameEnd = (userInfo.object(forKey: UIResponder.keyboardFrameEndUserInfoKey) as? NSValue)?.cgRectValue else { 34 | return nil 35 | } 36 | 37 | animationDuration = keyboardAnimationDuration 38 | var animationCurve = UIView.AnimationCurve.easeInOut 39 | NSNumber(value: keyboardAnimationCurve).getValue(&animationCurve) 40 | self.animationCurve = animationCurve 41 | isLocal = keyboardIsLocal 42 | frameBegin = keyboardFrameBegin 43 | frameEnd = keyboardFrameEnd 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Controller/Keyboard/KeyboardListenerDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // KeyboardListenerDelegate.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | protocol KeyboardListenerDelegate: AnyObject { 16 | func keyboardWillShow(info: KeyboardInfo) 17 | func keyboardDidShow(info: KeyboardInfo) 18 | func keyboardWillHide(info: KeyboardInfo) 19 | func keyboardDidHide(info: KeyboardInfo) 20 | func keyboardWillChangeFrame(info: KeyboardInfo) 21 | func keyboardDidChangeFrame(info: KeyboardInfo) 22 | } 23 | 24 | extension KeyboardListenerDelegate { 25 | func keyboardWillShow(info: KeyboardInfo) {} 26 | func keyboardDidShow(info: KeyboardInfo) {} 27 | func keyboardWillHide(info: KeyboardInfo) {} 28 | func keyboardDidHide(info: KeyboardInfo) {} 29 | func keyboardWillChangeFrame(info: KeyboardInfo) {} 30 | func keyboardDidChangeFrame(info: KeyboardInfo) {} 31 | } 32 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/Caches.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // Caches.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | let loader = CachingImageLoader(cache: imageCache, loader: DefaultImageLoader()) 16 | 17 | @available(iOS 13, *) 18 | var metadataCache = IterativeCache(mainCache: MetaDataCache(cache: MemoryDataCache()), 19 | backupCache: MetaDataCache(cache: PersistentDataCache(cacheFileExtension: "metadataCache"))) 20 | 21 | let imageCache = IterativeCache(mainCache: ImageForUrlCache(cache: MemoryDataCache()), 22 | backupCache: ImageForUrlCache(cache: PersistentDataCache())) 23 | 24 | //// Uncomment to reload dynamic content on every start. 25 | // @available(iOS 13, *) 26 | // var metadataCache = MetaDataCache(cache: MemoryDataCache()) 27 | // 28 | // let imageCache = ImageForUrlCache(cache: MemoryDataCache()) 29 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/Caching/AsyncKeyValueCaching.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // AsyncKeyValueCaching.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | public protocol AsyncKeyValueCaching: KeyValueCaching { 16 | associatedtype CachingKey 17 | 18 | associatedtype Entity 19 | 20 | func getEntity(for key: CachingKey, completion: @escaping (Result) -> Void) 21 | } 22 | 23 | public extension AsyncKeyValueCaching { 24 | func getEntity(for key: CachingKey, completion: @escaping (Result) -> Void) { 25 | DispatchQueue.global().async { 26 | do { 27 | let entity = try self.getEntity(for: key) 28 | DispatchQueue.main.async { 29 | completion(.success(entity)) 30 | } 31 | } catch { 32 | DispatchQueue.main.async { 33 | completion(.failure(error)) 34 | } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/Caching/CacheError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // CacheError.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | public enum CacheError: Error { 16 | case notFound 17 | 18 | case invalidData 19 | 20 | case custom(Error) 21 | } 22 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/Caching/Data/IterativeCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // IterativeCache.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | public final class IterativeCache: AsyncKeyValueCaching 17 | where 18 | FastCache.CachingKey == SlowCache.CachingKey, FastCache.Entity == SlowCache.Entity { 19 | public let mainCache: FastCache 20 | 21 | public let backupCache: SlowCache 22 | 23 | public init(mainCache: FastCache, backupCache: SlowCache) { 24 | self.mainCache = mainCache 25 | self.backupCache = backupCache 26 | } 27 | 28 | public func isEntityCached(for key: FastCache.CachingKey) -> Bool { 29 | mainCache.isEntityCached(for: key) || backupCache.isEntityCached(for: key) 30 | } 31 | 32 | public func getEntity(for key: FastCache.CachingKey) throws -> FastCache.Entity { 33 | if let image = try? mainCache.getEntity(for: key) { 34 | image 35 | } else { 36 | try backupCache.getEntity(for: key) 37 | } 38 | } 39 | 40 | public func getEntity(for key: FastCache.CachingKey, completion: @escaping (Result) -> Void) { 41 | mainCache.getEntity(for: key, completion: { result in 42 | guard case .failure = result else { 43 | completion(result) 44 | return 45 | } 46 | 47 | self.backupCache.getEntity(for: key, completion: { result in 48 | switch result { 49 | case let .success(image): 50 | completion(.success(image)) 51 | DispatchQueue.global(qos: .utility).async { 52 | try? self.mainCache.store(entity: image, for: key) 53 | } 54 | case let .failure(error): 55 | completion(.failure(error)) 56 | } 57 | }) 58 | }) 59 | } 60 | 61 | public func store(entity: FastCache.Entity, for key: FastCache.CachingKey) throws { 62 | try mainCache.store(entity: entity, for: key) 63 | try backupCache.store(entity: entity, for: key) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/Caching/Data/MemoryDataCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // MemoryDataCache.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | public final class MemoryDataCache: AsyncKeyValueCaching { 16 | private final class WrappedKey: NSObject { 17 | let key: CachingKey 18 | 19 | init(_ key: CachingKey) { 20 | self.key = key 21 | } 22 | 23 | override var hash: Int { 24 | key.hashValue 25 | } 26 | 27 | override func isEqual(_ object: Any?) -> Bool { 28 | guard let value = object as? WrappedKey else { 29 | return false 30 | } 31 | 32 | return value.key == key 33 | } 34 | } 35 | 36 | private final class Entry { 37 | let data: Data 38 | 39 | init(_ data: Data) { 40 | self.data = data 41 | } 42 | } 43 | 44 | private let cache = NSCache() 45 | 46 | private let lock = NSLock() 47 | 48 | public init() { 49 | cache.countLimit = Int.max 50 | } 51 | 52 | public func isEntityCached(for key: CachingKey) -> Bool { 53 | cache.object(forKey: WrappedKey(key)) != nil 54 | } 55 | 56 | public func getEntity(for key: CachingKey) throws -> Data { 57 | lock.lock() 58 | defer { 59 | lock.unlock() 60 | } 61 | 62 | guard let entry = cache.object(forKey: WrappedKey(key)) else { 63 | throw CacheError.notFound 64 | } 65 | 66 | return entry.data 67 | } 68 | 69 | public func getEntity(for key: CachingKey, completion: @escaping (Result) -> Void) { 70 | guard let data = try? getEntity(for: key) else { 71 | completion(.failure(CacheError.notFound)) 72 | return 73 | } 74 | 75 | completion(.success(data)) 76 | } 77 | 78 | public func store(entity: Data, for key: CachingKey) { 79 | cache.setObject(Entry(entity), forKey: WrappedKey(key), cost: Int(Date().timeIntervalSince1970)) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/Caching/Data/PersistentlyCacheable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // PersistentlyCacheable.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | protocol PersistentlyCacheable { 16 | var persistentIdentifier: String { get } 17 | } 18 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/Caching/Image/CacheableImageKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // CacheableImageKey.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | public struct CacheableImageKey: Hashable, PersistentlyCacheable { 17 | public let url: URL 18 | 19 | var persistentIdentifier: String { 20 | url.absoluteString.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? url.absoluteString 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/Caching/Image/ImageForUrlCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ImageForUrlCache.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | public final class ImageForUrlCache: AsyncKeyValueCaching where Cache.CachingKey: Hashable, Cache.Entity == Data { 17 | private let cache: Cache 18 | 19 | public init(cache: Cache) { 20 | self.cache = cache 21 | } 22 | 23 | public func isEntityCached(for key: CachingKey) -> Bool { 24 | cache.isEntityCached(for: key) 25 | } 26 | 27 | public func getEntity(for key: CachingKey) throws -> UIImage { 28 | let data = try cache.getEntity(for: key) 29 | guard let image = UIImage(data: data, scale: 1) else { 30 | throw CacheError.invalidData 31 | } 32 | return image 33 | } 34 | 35 | public func getEntity(for key: Cache.CachingKey, completion: @escaping (Result) -> Void) { 36 | cache.getEntity(for: key, completion: { result in 37 | DispatchQueue.global(qos: .utility).async { 38 | switch result { 39 | case let .success(data): 40 | guard let image = UIImage(data: data) else { 41 | DispatchQueue.main.async { 42 | completion(.failure(CacheError.invalidData)) 43 | } 44 | return 45 | } 46 | DispatchQueue.main.async { 47 | completion(.success(image)) 48 | } 49 | case let .failure(error): 50 | DispatchQueue.main.async { 51 | completion(.failure(error)) 52 | } 53 | } 54 | } 55 | }) 56 | } 57 | 58 | public func store(entity: UIImage, for key: Cache.CachingKey) throws { 59 | guard let data = entity.jpegData(compressionQuality: 1.0) else { 60 | return 61 | } 62 | try cache.store(entity: data, for: key) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/Caching/KeyValueCaching.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // KeyValueCaching.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | public protocol KeyValueCaching { 16 | associatedtype CachingKey 17 | 18 | associatedtype Entity 19 | 20 | func isEntityCached(for key: CachingKey) -> Bool 21 | 22 | func getEntity(for key: CachingKey) throws -> Entity 23 | 24 | func store(entity: Entity, for key: CachingKey) throws 25 | } 26 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/Caching/Metadata/MetaDataCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // MetaDataCache.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import LinkPresentation 15 | import UIKit 16 | 17 | @available(iOS 13, *) 18 | final class MetaDataCache: AsyncKeyValueCaching where Cache.CachingKey == URL, Cache.Entity == Data { 19 | private var cache: Cache 20 | 21 | init(cache: Cache) { 22 | self.cache = cache 23 | } 24 | 25 | func isEntityCached(for url: URL) -> Bool { 26 | cache.isEntityCached(for: url) 27 | } 28 | 29 | func getEntity(for url: URL) throws -> LPLinkMetadata { 30 | let data = try cache.getEntity(for: url) 31 | guard let entity = try NSKeyedUnarchiver.unarchivedObject(ofClass: LPLinkMetadata.self, from: data) else { 32 | throw CacheError.invalidData 33 | } 34 | return entity 35 | } 36 | 37 | func getEntity(for key: URL, completion: @escaping (Result) -> Void) { 38 | DispatchQueue.global().async { 39 | do { 40 | let entity = try self.getEntity(for: key) 41 | DispatchQueue.main.async { 42 | completion(.success(entity)) 43 | } 44 | } catch { 45 | DispatchQueue.main.async { 46 | completion(.failure(error)) 47 | } 48 | } 49 | } 50 | } 51 | 52 | func store(entity: LPLinkMetadata, for key: URL) throws { 53 | let codedData = try NSKeyedArchiver.archivedData(withRootObject: entity, requiringSecureCoding: true) 54 | try cache.store(entity: codedData, for: key) 55 | } 56 | } 57 | 58 | extension URL: PersistentlyCacheable { 59 | var persistentIdentifier: String { 60 | guard let percentEncoding = absoluteString.addingPercentEncoding(withAllowedCharacters: .alphanumerics) else { 61 | fatalError() 62 | } 63 | return percentEncoding 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/ChatDateFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ChatDateFormatter.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | public final class ChatDateFormatter { 16 | // MARK: - Properties 17 | 18 | public static let shared = ChatDateFormatter() 19 | 20 | private let formatter = DateFormatter() 21 | 22 | // MARK: - Initializer 23 | 24 | private init() {} 25 | 26 | // MARK: - Methods 27 | 28 | public func string(from date: Date) -> String { 29 | configureDateFormatter(for: date) 30 | return formatter.string(from: date) 31 | } 32 | 33 | public func attributedString(from date: Date, with attributes: [NSAttributedString.Key: Any]) -> NSAttributedString { 34 | let dateString = string(from: date) 35 | return NSAttributedString(string: dateString, attributes: attributes) 36 | } 37 | 38 | func configureDateFormatter(for date: Date) { 39 | switch true { 40 | case Calendar.current.isDateInToday(date) || Calendar.current.isDateInYesterday(date): 41 | formatter.doesRelativeDateFormatting = true 42 | formatter.dateStyle = .short 43 | formatter.timeStyle = .short 44 | case Calendar.current.isDate(date, equalTo: Date(), toGranularity: .weekOfYear): 45 | formatter.dateFormat = "EEEE hh:mm" 46 | case Calendar.current.isDate(date, equalTo: Date(), toGranularity: .year): 47 | formatter.dateFormat = "E, d MMM, hh:mm" 48 | default: 49 | formatter.dateFormat = "MMM d, yyyy, hh:mm" 50 | } 51 | } 52 | } 53 | 54 | public final class MessageDateFormatter { 55 | public static let shared = MessageDateFormatter() 56 | 57 | private let formatter = DateFormatter() 58 | 59 | private init() { 60 | formatter.doesRelativeDateFormatting = true 61 | formatter.dateStyle = .none 62 | formatter.timeStyle = .short 63 | } 64 | 65 | public func string(from date: Date) -> String { 66 | formatter.string(from: date) 67 | } 68 | 69 | public func attributedString(from date: Date, with attributes: [NSAttributedString.Key: Any]) -> NSAttributedString { 70 | let dateString = string(from: date) 71 | return NSAttributedString(string: dateString, attributes: attributes) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/Data Objects/Cell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // Cell.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import ChatLayout 14 | import DifferenceKit 15 | import Foundation 16 | import UIKit 17 | 18 | enum Cell: Hashable { 19 | enum BubbleType { 20 | case normal 21 | case tailed 22 | } 23 | 24 | case message(Message, bubbleType: BubbleType) 25 | 26 | case typingIndicator 27 | 28 | case messageGroup(MessageGroup) 29 | 30 | case date(DateGroup) 31 | 32 | var alignment: ChatItemAlignment { 33 | switch self { 34 | case let .message(message, _): 35 | message.type == .incoming ? .leading : .trailing 36 | case .typingIndicator: 37 | .leading 38 | case let .messageGroup(group): 39 | group.type == .incoming ? .leading : .trailing 40 | case .date: 41 | .center 42 | } 43 | } 44 | } 45 | 46 | extension Cell: Differentiable { 47 | public var differenceIdentifier: Int { 48 | switch self { 49 | case let .message(message, _): 50 | message.differenceIdentifier 51 | case .typingIndicator: 52 | hashValue 53 | case let .messageGroup(group): 54 | group.differenceIdentifier 55 | case let .date(group): 56 | group.differenceIdentifier 57 | } 58 | } 59 | 60 | public func isContentEqual(to source: Cell) -> Bool { 61 | self == source 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/Data Objects/Message.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // Message.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import ChatLayout 14 | import DifferenceKit 15 | import Foundation 16 | 17 | enum MessageType: Hashable { 18 | case incoming 19 | 20 | case outgoing 21 | 22 | var isIncoming: Bool { 23 | self == .incoming 24 | } 25 | } 26 | 27 | enum MessageStatus: Hashable { 28 | case sent 29 | 30 | case received 31 | 32 | case read 33 | } 34 | 35 | extension ChatItemAlignment { 36 | var isIncoming: Bool { 37 | self == .leading 38 | } 39 | } 40 | 41 | struct DateGroup: Hashable { 42 | var id: UUID 43 | 44 | var date: Date 45 | 46 | var value: String { 47 | ChatDateFormatter.shared.string(from: date) 48 | } 49 | 50 | init(id: UUID, date: Date) { 51 | self.id = id 52 | self.date = date 53 | } 54 | } 55 | 56 | extension DateGroup: Differentiable { 57 | public var differenceIdentifier: Int { 58 | hashValue 59 | } 60 | 61 | public func isContentEqual(to source: DateGroup) -> Bool { 62 | self == source 63 | } 64 | } 65 | 66 | struct MessageGroup: Hashable { 67 | var id: UUID 68 | 69 | var title: String 70 | 71 | var type: MessageType 72 | 73 | init(id: UUID, title: String, type: MessageType) { 74 | self.id = id 75 | self.title = title 76 | self.type = type 77 | } 78 | } 79 | 80 | extension MessageGroup: Differentiable { 81 | public var differenceIdentifier: Int { 82 | hashValue 83 | } 84 | 85 | public func isContentEqual(to source: MessageGroup) -> Bool { 86 | self == source 87 | } 88 | } 89 | 90 | struct Message: Hashable { 91 | enum Data: Hashable { 92 | case text(String) 93 | 94 | case url(URL, isLocallyStored: Bool) 95 | 96 | case image(ImageMessageSource, isLocallyStored: Bool) 97 | } 98 | 99 | var id: UUID 100 | 101 | var date: Date 102 | 103 | var data: Data 104 | 105 | var owner: User 106 | 107 | var type: MessageType 108 | 109 | var status: MessageStatus = .sent 110 | } 111 | 112 | extension Message: Differentiable { 113 | public var differenceIdentifier: Int { 114 | id.hashValue 115 | } 116 | 117 | public func isContentEqual(to source: Message) -> Bool { 118 | self == source 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/Data Objects/RawMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // RawMessage.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | struct RawMessage: Hashable { 17 | enum Data: Hashable { 18 | case text(String) 19 | 20 | case url(URL) 21 | 22 | case image(ImageMessageSource) 23 | } 24 | 25 | var id: UUID 26 | 27 | var date: Date 28 | 29 | var data: Data 30 | 31 | var userId: Int 32 | 33 | var status: MessageStatus = .sent 34 | } 35 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/Data Objects/Section.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // Section.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import DifferenceKit 14 | import Foundation 15 | 16 | struct Section: Hashable { 17 | var id: Int 18 | 19 | var title: String 20 | 21 | var cells: [Cell] 22 | } 23 | 24 | extension Section: DifferentiableSection { 25 | public var differenceIdentifier: Int { 26 | id 27 | } 28 | 29 | public func isContentEqual(to source: Section) -> Bool { 30 | id == source.id 31 | } 32 | 33 | public var elements: [Cell] { 34 | cells 35 | } 36 | 37 | public init(source: Section, elements: C) where C.Element == Cell { 38 | self.init(id: source.id, title: source.title, cells: Array(elements)) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/Data Objects/TypingState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // TypingState.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | enum TypingState { 16 | case idle 17 | 18 | case typing 19 | } 20 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/Data Objects/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // User.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import DifferenceKit 14 | import Foundation 15 | import UIKit 16 | 17 | struct User: Hashable { 18 | var id: Int 19 | 20 | var name: String { 21 | switch id { 22 | case 0: 23 | "Chat Layout" 24 | case 1: 25 | "Eugene Kazaev" 26 | case 2: 27 | "Cathal Murphy" 28 | case 3: 29 | "Aliaksandra Mikhailouskaya" 30 | default: 31 | fatalError() 32 | } 33 | } 34 | } 35 | 36 | extension User: Differentiable {} 37 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/Model/TextGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // TextGenerator.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | public class TextGenerator { 16 | private static let words = [ 17 | "alias", "consequatur", "aut", "perferendis", "sit", "voluptatem", 18 | "accusantium", "doloremque", "aperiam", "eaque", "ipsa", "quae", "ab", 19 | "illo", "inventore", "veritatis", "et", "quasi", "architecto", 20 | "beatae", "vitae", "dicta", "sunt", "explicabo", "aspernatur", "aut", 21 | "odit", "aut", "fugit", "sed", "quia", "consequuntur", "magni", 22 | "dolores", "eos", "qui", "ratione", "voluptatem", "sequi", "nesciunt", 23 | "neque", "dolorem", "ipsum", "quia", "dolor", "sit", "amet", 24 | "consectetur", "adipisci", "velit", "sed", "quia", "non", "numquam", 25 | "eius", "modi", "tempora", "incidunt", "ut", "labore", "et", "dolore", 26 | "magnam", "aliquam", "quaerat", "voluptatem", "ut", "enim", "ad", 27 | "minima", "veniam", "quis", "nostrum", "exercitationem", "ullam", 28 | "corporis", "nemo", "enim", "ipsam", "voluptatem", "quia", "voluptas", 29 | "sit", "suscipit", "laboriosam", "nisi", "ut", "aliquid", "ex", "ea", 30 | "commodi", "consequatur", "quis", "autem", "vel", "eum", "iure", 31 | "reprehenderit", "qui", "in", "ea", "voluptate", "velit", "esse", 32 | "quam", "nihil", "molestiae", "et", "iusto", "odio", "dignissimos", 33 | "ducimus", "qui", "blanditiis", "praesentium", "laudantium", "totam", 34 | "rem", "voluptatum", "deleniti", "atque", "corrupti", "quos", 35 | "dolores", "et", "quas", "molestias", "excepturi", "sint", 36 | "occaecati", "cupiditate", "non", "provident", "sed", "ut", 37 | "perspiciatis", "unde", "omnis", "iste", "natus", "error", 38 | "similique", "sunt", "in", "culpa", "qui", "officia", "deserunt", 39 | "mollitia", "animi", "id", "est", "laborum", "et", "dolorum", "fuga", 40 | "et", "harum", "quidem", "rerum", "facilis", "est", "et", "expedita", 41 | "distinctio", "nam", "libero", "tempore", "cum", "soluta", "nobis", 42 | "est", "eligendi", "optio", "cumque", "nihil", "impedit", "quo", 43 | "porro", "quisquam", "est", "qui", "minus", "id", "quod", "maxime", 44 | "placeat", "facere", "possimus", "omnis", "voluptas", "assumenda", 45 | "est", "omnis", "dolor", "repellendus", "temporibus", "autem", 46 | "quibusdam", "et", "aut", "consequatur", "vel", "illum", "qui", 47 | "dolorem", "eum", "fugiat", "quo", "voluptas", "nulla", "pariatur", 48 | "at", "vero", "eos", "et", "accusamus", "officiis", "debitis", "aut", 49 | "rerum", "necessitatibus", "saepe", "eveniet", "ut", "et", 50 | "voluptates", "repudiandae", "sint", "et", "molestiae", "non", 51 | "recusandae", "itaque", "earum", "rerum", "hic", "tenetur", "a", 52 | "sapiente", "delectus", "ut", "aut", "reiciendis", "voluptatibus", 53 | "maiores", "doloribus", "asperiores", "repellat" 54 | ] 55 | 56 | public class func getString(of wordsCount: Int = 6) -> String { 57 | if wordsCount <= 0 { 58 | return "" 59 | } 60 | 61 | var result = words.shuffled().prefix(wordsCount).joined(separator: " ") 62 | result.replaceSubrange(result.startIndex...result.startIndex, with: String(result[result.startIndex]).capitalized) 63 | return result + "." 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Avatar View/AvatarPlaceholderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // AvatarPlaceholderView.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import ChatLayout 14 | import Foundation 15 | import UIKit 16 | 17 | final class AvatarPlaceholderView: UIView, StaticViewFactory { 18 | override init(frame: CGRect) { 19 | super.init(frame: frame) 20 | setupSubviews() 21 | } 22 | 23 | required init?(coder: NSCoder) { 24 | super.init(coder: coder) 25 | setupSubviews() 26 | } 27 | 28 | private func setupSubviews() { 29 | translatesAutoresizingMaskIntoConstraints = false 30 | insetsLayoutMarginsFromSafeArea = false 31 | layoutMargins = .zero 32 | let constraint = widthAnchor.constraint(equalToConstant: 30) 33 | constraint.priority = UILayoutPriority(rawValue: 999) 34 | constraint.isActive = true 35 | heightAnchor.constraint(equalTo: widthAnchor, multiplier: 1).isActive = true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Avatar View/AvatarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // AvatarView.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import ChatLayout 14 | import Foundation 15 | import UIKit 16 | 17 | // Just to visually test `ChatLayout.supportSelfSizingInvalidation` 18 | protocol AvatarViewDelegate: AnyObject { 19 | func avatarTapped() 20 | } 21 | 22 | final class AvatarView: UIView, StaticViewFactory { 23 | weak var delegate: AvatarViewDelegate? 24 | 25 | private lazy var circleImageView = RoundedCornersContainerView(frame: bounds) 26 | 27 | private var controller: AvatarViewController? 28 | 29 | override init(frame: CGRect) { 30 | super.init(frame: frame) 31 | setupSubviews() 32 | } 33 | 34 | required init?(coder: NSCoder) { 35 | super.init(coder: coder) 36 | setupSubviews() 37 | } 38 | 39 | func reloadData() { 40 | guard let controller else { 41 | return 42 | } 43 | UIView.performWithoutAnimation { 44 | circleImageView.customView.image = controller.image 45 | } 46 | } 47 | 48 | func setup(with controller: AvatarViewController) { 49 | self.controller = controller 50 | } 51 | 52 | private func setupSubviews() { 53 | translatesAutoresizingMaskIntoConstraints = false 54 | insetsLayoutMarginsFromSafeArea = false 55 | layoutMargins = .zero 56 | addSubview(circleImageView) 57 | 58 | circleImageView.translatesAutoresizingMaskIntoConstraints = false 59 | NSLayoutConstraint.activate([ 60 | circleImageView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 61 | circleImageView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), 62 | circleImageView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), 63 | circleImageView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor) 64 | ]) 65 | 66 | let constraint = circleImageView.widthAnchor.constraint(equalToConstant: 30) 67 | constraint.priority = UILayoutPriority(rawValue: 999) 68 | constraint.isActive = true 69 | circleImageView.heightAnchor.constraint(equalTo: circleImageView.widthAnchor, multiplier: 1).isActive = true 70 | 71 | circleImageView.customView.contentMode = .scaleAspectFill 72 | 73 | let gestureRecogniser = UITapGestureRecognizer() 74 | circleImageView.addGestureRecognizer(gestureRecogniser) 75 | gestureRecogniser.addTarget(self, action: #selector(avatarTapped)) 76 | } 77 | 78 | @objc 79 | private func avatarTapped() { 80 | delegate?.avatarTapped() 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Avatar View/AvatarViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // AvatarViewController.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | final class AvatarViewController { 17 | var image: UIImage? { 18 | guard bubble == .tailed else { 19 | return nil 20 | } 21 | switch user.id { 22 | case 0: 23 | return nil 24 | case 1: 25 | return UIImage(named: "Eugene") 26 | case 2: 27 | return UIImage(named: "Cathal") 28 | case 3: 29 | return UIImage(named: "Sasha") 30 | default: 31 | fatalError("Support for the user id \(user.id) is not implemented.") 32 | } 33 | } 34 | 35 | private let user: User 36 | 37 | private let bubble: Cell.BubbleType 38 | 39 | weak var view: AvatarView? { 40 | didSet { 41 | view?.reloadData() 42 | } 43 | } 44 | 45 | init(user: User, bubble: Cell.BubbleType) { 46 | self.user = user 47 | self.bubble = bubble 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Data Source/ChatCollectionDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ChatCollectionDataSource.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import ChatLayout 14 | import Foundation 15 | import UIKit 16 | 17 | protocol ChatCollectionDataSource: UICollectionViewDataSource, ChatLayoutDelegate { 18 | var sections: [Section] { get set } 19 | 20 | func prepare(with collectionView: UICollectionView) 21 | } 22 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Date Accessory View/DateAccessoryController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // DateAccessoryController.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | final class DateAccessoryController { 16 | private let date: Date 17 | 18 | let accessoryText: String 19 | 20 | init(date: Date) { 21 | self.date = date 22 | accessoryText = MessageDateFormatter.shared.string(from: date) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Date Accessory View/DateAccessoryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // DateAccessoryView.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | final class DateAccessoryView: UIView { 17 | private var accessoryView = UILabel() 18 | 19 | private var controller: DateAccessoryController? 20 | 21 | override init(frame: CGRect) { 22 | super.init(frame: frame) 23 | setupSubviews() 24 | } 25 | 26 | required init?(coder: NSCoder) { 27 | super.init(coder: coder) 28 | setupSubviews() 29 | } 30 | 31 | func setup(with controller: DateAccessoryController) { 32 | self.controller = controller 33 | reloadData() 34 | } 35 | 36 | private func reloadData() { 37 | accessoryView.text = controller?.accessoryText 38 | } 39 | 40 | private func setupSubviews() { 41 | translatesAutoresizingMaskIntoConstraints = false 42 | insetsLayoutMarginsFromSafeArea = false 43 | layoutMargins = .zero 44 | 45 | addSubview(accessoryView) 46 | NSLayoutConstraint.activate([ 47 | accessoryView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 48 | accessoryView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), 49 | accessoryView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), 50 | accessoryView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor) 51 | ]) 52 | 53 | accessoryView.translatesAutoresizingMaskIntoConstraints = false 54 | 55 | accessoryView.font = UIFont.preferredFont(forTextStyle: .caption1) 56 | accessoryView.textColor = .gray 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Editing Accessory View/EditingAccessoryController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // EditingAccessoryController.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | protocol EditingAccessoryControllerDelegate: AnyObject { 17 | func deleteMessage(with id: UUID) 18 | } 19 | 20 | final class EditingAccessoryController { 21 | weak var delegate: EditingAccessoryControllerDelegate? 22 | 23 | weak var view: EditingAccessoryView? 24 | 25 | private let messageId: UUID 26 | 27 | init(messageId: UUID) { 28 | self.messageId = messageId 29 | } 30 | 31 | func deleteButtonTapped() { 32 | delegate?.deleteMessage(with: messageId) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Editing Accessory View/EditingAccessoryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // EditingAccessoryView.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import ChatLayout 14 | import Foundation 15 | import UIKit 16 | 17 | final class EditingAccessoryView: UIView, StaticViewFactory { 18 | private lazy var button = UIButton(type: .system) 19 | 20 | override init(frame: CGRect) { 21 | super.init(frame: frame) 22 | setupSubviews() 23 | } 24 | 25 | private var controller: EditingAccessoryController? 26 | 27 | required init?(coder: NSCoder) { 28 | super.init(coder: coder) 29 | setupSubviews() 30 | } 31 | 32 | private func setupSubviews() { 33 | translatesAutoresizingMaskIntoConstraints = false 34 | insetsLayoutMarginsFromSafeArea = false 35 | layoutMargins = .zero 36 | addSubview(button) 37 | 38 | button.translatesAutoresizingMaskIntoConstraints = false 39 | NSLayoutConstraint.activate([ 40 | button.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 41 | button.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), 42 | button.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), 43 | button.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor) 44 | ]) 45 | 46 | button.setTitle("Delete", for: .normal) 47 | button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) 48 | } 49 | 50 | func setup(with controller: EditingAccessoryController) { 51 | self.controller = controller 52 | } 53 | 54 | @objc 55 | private func buttonTapped() { 56 | controller?.deleteButtonTapped() 57 | } 58 | } 59 | 60 | extension EditingAccessoryView: EditNotifierDelegate { 61 | var isEditing: Bool { 62 | get { 63 | !isHidden 64 | } 65 | set { 66 | guard isHidden == newValue else { 67 | return 68 | } 69 | isHidden = !newValue 70 | alpha = newValue ? 1 : 0 71 | } 72 | } 73 | 74 | public func setIsEditing(_ isEditing: Bool, duration: ActionDuration = .notAnimated) { 75 | guard case let .animated(duration) = duration else { 76 | self.isEditing = isEditing 77 | return 78 | } 79 | 80 | UIView.animate(withDuration: duration) { 81 | self.isEditing = isEditing 82 | self.setNeedsLayout() 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Image View/ImageController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ImageController.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | final class ImageController { 17 | weak var view: ImageView? { 18 | didSet { 19 | UIView.performWithoutAnimation { 20 | view?.reloadData() 21 | } 22 | } 23 | } 24 | 25 | weak var delegate: ReloadDelegate? 26 | 27 | var state: ImageViewState { 28 | guard let image else { 29 | return .loading 30 | } 31 | return .image(image) 32 | } 33 | 34 | private var image: UIImage? 35 | 36 | private let messageId: UUID 37 | 38 | private let source: ImageMessageSource 39 | 40 | private let bubbleController: BubbleController 41 | 42 | init(source: ImageMessageSource, messageId: UUID, bubbleController: BubbleController) { 43 | self.source = source 44 | self.messageId = messageId 45 | self.bubbleController = bubbleController 46 | loadImage() 47 | } 48 | 49 | private func loadImage() { 50 | switch source { 51 | case let .imageURL(url): 52 | if let image = try? imageCache.getEntity(for: .init(url: url)) { 53 | self.image = image 54 | view?.reloadData() 55 | } else { 56 | loader.loadImage(from: url) { [weak self] result in 57 | guard let self, 58 | case let .success(image) = result else { 59 | return 60 | } 61 | if #available(iOS 16.0, *), 62 | enableSelfSizingSupport { 63 | self.image = image 64 | view?.reloadData() 65 | } else { 66 | delegate?.reloadMessage(with: messageId) 67 | } 68 | } 69 | } 70 | case let .image(image): 71 | self.image = image 72 | view?.reloadData() 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Image View/ImageViewState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ImageViewState.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | enum ImageViewState { 17 | case loading 18 | 19 | case image(UIImage) 20 | } 21 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Other/BezierBubbleController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // BezierBubbleController.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | final class BezierBubbleController: BubbleController { 17 | private let controllerProxy: BubbleController 18 | 19 | private let type: MessageType 20 | 21 | private let bubbleType: Cell.BubbleType 22 | 23 | weak var bubbleView: BezierMaskedView? { 24 | didSet { 25 | setupBubbleView() 26 | } 27 | } 28 | 29 | init(bubbleView: BezierMaskedView, controllerProxy: BubbleController, type: MessageType, bubbleType: Cell.BubbleType) { 30 | self.controllerProxy = controllerProxy 31 | self.type = type 32 | self.bubbleType = bubbleType 33 | self.bubbleView = bubbleView 34 | setupBubbleView() 35 | } 36 | 37 | private func setupBubbleView() { 38 | guard let bubbleView else { 39 | return 40 | } 41 | 42 | bubbleView.messageType = type 43 | bubbleView.bubbleType = bubbleType 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Other/BubbleController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // BubbleController.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | protocol BubbleController {} 16 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Other/EditNotifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // EditNotifier.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | final class EditNotifier { 17 | private(set) var isEditing = false 18 | 19 | private var delegates = NSHashTable.weakObjects() 20 | 21 | func add(delegate: EditNotifierDelegate) { 22 | delegates.add(delegate) 23 | } 24 | 25 | func setIsEditing(_ isEditing: Bool, duration: ActionDuration) { 26 | self.isEditing = isEditing 27 | delegates.allObjects.compactMap { $0 as? EditNotifierDelegate }.forEach { $0.setIsEditing(isEditing, duration: duration) } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Other/EditNotifierDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // EditNotifierDelegate.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | public enum ActionDuration { 17 | case notAnimated 18 | case animated(duration: TimeInterval) 19 | } 20 | 21 | public protocol EditNotifierDelegate: AnyObject { 22 | func setIsEditing(_ isEditing: Bool, duration: ActionDuration) 23 | } 24 | 25 | public extension EditNotifierDelegate { 26 | func setIsEditing(_ isEditing: Bool, duration: ActionDuration) {} 27 | } 28 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Other/FullCellContentBubbleController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // FullCellContentBubbleController.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import ChatLayout 14 | import Foundation 15 | import UIKit 16 | 17 | final class FullCellContentBubbleController: BubbleController { 18 | weak var bubbleView: BezierMaskedView? { 19 | didSet { 20 | setupBubbleView() 21 | } 22 | } 23 | 24 | init(bubbleView: BezierMaskedView) { 25 | self.bubbleView = bubbleView 26 | setupBubbleView() 27 | } 28 | 29 | private func setupBubbleView() { 30 | guard let bubbleView else { 31 | return 32 | } 33 | 34 | UIView.performWithoutAnimation { 35 | bubbleView.backgroundColor = .clear 36 | bubbleView.customView.layoutMargins = .zero 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Other/ManualAnimator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ManualAnimator.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | // Read why this class is needed here: 17 | // https://dasdom.dev/posts/scrolling-a-collection-view-with-custom-duration/ 18 | class ManualAnimator { 19 | enum AnimationCurve { 20 | case linear 21 | case parametric 22 | case easeInOut 23 | case easeIn 24 | case easeOut 25 | 26 | func modify(_ x: CGFloat) -> CGFloat { 27 | switch self { 28 | case .linear: 29 | x 30 | case .parametric: 31 | x.parametric 32 | case .easeInOut: 33 | x.quadraticEaseInOut 34 | case .easeIn: 35 | x.quadraticEaseIn 36 | case .easeOut: 37 | x.quadraticEaseOut 38 | } 39 | } 40 | } 41 | 42 | private var displayLink: CADisplayLink? 43 | private var start = Date() 44 | private var total = TimeInterval(0) 45 | private var closure: ((CGFloat) -> Void)? 46 | private var animationCurve: AnimationCurve = .linear 47 | 48 | func animate(duration: TimeInterval, curve: AnimationCurve = .linear, _ animations: @escaping (CGFloat) -> Void) { 49 | guard duration > 0 else { 50 | animations(1.0); return 51 | } 52 | reset() 53 | start = Date() 54 | closure = animations 55 | total = duration 56 | animationCurve = curve 57 | let d = CADisplayLink(target: self, selector: #selector(tick)) 58 | d.add(to: .current, forMode: .common) 59 | displayLink = d 60 | } 61 | 62 | @objc 63 | private func tick() { 64 | let delta = Date().timeIntervalSince(start) 65 | var percentage = animationCurve.modify(CGFloat(delta) / CGFloat(total)) 66 | if percentage < 0.0 { 67 | percentage = 0.0 68 | } else if percentage >= 1.0 { 69 | percentage = 1.0 70 | reset() 71 | } 72 | closure?(percentage) 73 | } 74 | 75 | private func reset() { 76 | displayLink?.invalidate() 77 | displayLink = nil 78 | } 79 | } 80 | 81 | private extension CGFloat { 82 | var parametric: CGFloat { 83 | guard self > 0.0 else { 84 | return 0.0 85 | } 86 | guard self < 1.0 else { 87 | return 1.0 88 | } 89 | return (self * self) / (2.0 * ((self * self) - self) + 1.0) 90 | } 91 | 92 | var quadraticEaseInOut: CGFloat { 93 | guard self > 0.0 else { 94 | return 0.0 95 | } 96 | guard self < 1.0 else { 97 | return 1.0 98 | } 99 | if self < 0.5 { 100 | return 2 * self * self 101 | } 102 | return (-2 * self * self) + (4 * self) - 1 103 | } 104 | 105 | var quadraticEaseOut: CGFloat { 106 | guard self > 0.0 else { 107 | return 0.0 108 | } 109 | guard self < 1.0 else { 110 | return 1.0 111 | } 112 | return -self * (self - 2) 113 | } 114 | 115 | var quadraticEaseIn: CGFloat { 116 | guard self > 0.0 else { 117 | return 0.0 118 | } 119 | guard self < 1.0 else { 120 | return 1.0 121 | } 122 | return self * self 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Other/SwipeNotifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // SwipeNotifier.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | public protocol SwipeNotifierDelegate: AnyObject { 17 | var swipeCompletionRate: CGFloat { get set } 18 | 19 | var accessorySafeAreaInsets: UIEdgeInsets { get set } 20 | } 21 | 22 | final class SwipeNotifier { 23 | private var delegates = NSHashTable.weakObjects() 24 | 25 | private(set) var accessorySafeAreaInsets: UIEdgeInsets = .zero 26 | 27 | private(set) var swipeCompletionRate: CGFloat = 0 28 | 29 | func add(delegate: SwipeNotifierDelegate) { 30 | delegates.add(delegate) 31 | } 32 | 33 | func setSwipeCompletionRate(_ swipeCompletionRate: CGFloat) { 34 | self.swipeCompletionRate = swipeCompletionRate 35 | delegates.allObjects.compactMap { $0 as? SwipeNotifierDelegate }.forEach { $0.swipeCompletionRate = swipeCompletionRate } 36 | } 37 | 38 | func setAccessoryOffset(_ accessorySafeAreaInsets: UIEdgeInsets) { 39 | self.accessorySafeAreaInsets = accessorySafeAreaInsets 40 | delegates.allObjects.compactMap { $0 as? SwipeNotifierDelegate }.forEach { $0.accessorySafeAreaInsets = accessorySafeAreaInsets } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Other/TextBubbleController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // TextBubbleController.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import ChatLayout 14 | import Foundation 15 | import UIKit 16 | 17 | final class TextBubbleController: BubbleController { 18 | private let type: MessageType 19 | 20 | private let bubbleType: Cell.BubbleType 21 | 22 | weak var bubbleView: UIView? { 23 | didSet { 24 | setupBubbleView() 25 | } 26 | } 27 | 28 | init(bubbleView: UIView, type: MessageType, bubbleType: Cell.BubbleType) { 29 | self.type = type 30 | self.bubbleType = bubbleType 31 | self.bubbleView = bubbleView 32 | setupBubbleView() 33 | } 34 | 35 | private func setupBubbleView() { 36 | guard let bubbleView else { 37 | return 38 | } 39 | UIView.performWithoutAnimation { 40 | let marginOffset: CGFloat = type.isIncoming ? -Constants.tailSize : Constants.tailSize 41 | let edgeInsets = UIEdgeInsets(top: 8, left: 16 - marginOffset, bottom: 8, right: 16 + marginOffset) 42 | bubbleView.layoutMargins = edgeInsets 43 | 44 | if #available(iOS 13.0, *) { 45 | bubbleView.backgroundColor = type.isIncoming ? .systemGray5 : .systemBlue 46 | } else { 47 | let systemGray5 = UIColor(red: 200 / 255, green: 200 / 255, blue: 200 / 255, alpha: 1) 48 | bubbleView.backgroundColor = type.isIncoming ? systemGray5 : .systemBlue 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Other/UIView+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // UIView+Extension.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import UIKit 15 | 16 | extension UIView { 17 | func superview(of type: T.Type) -> T? { 18 | superview as? T ?? superview.flatMap { $0.superview(of: type) } 19 | } 20 | 21 | func subview(of type: T.Type) -> T? { 22 | subviews.compactMap { $0 as? T ?? $0.subview(of: type) }.first 23 | } 24 | 25 | // Even though we do not set it animated - it can happen during the animated batch update 26 | // http://www.openradar.me/25087688 27 | // https://github.com/nkukushkin/StackView-Hiding-With-Animation-Bug-Example 28 | var isHiddenSafe: Bool { 29 | get { 30 | isHidden 31 | } 32 | set { 33 | guard isHidden != newValue else { 34 | return 35 | } 36 | isHidden = newValue 37 | } 38 | } 39 | } 40 | 41 | extension UIViewController { 42 | // https://github.com/ekazaev/route-composer can do it better 43 | func topMostViewController() -> UIViewController { 44 | if presentedViewController == nil { 45 | return self 46 | } 47 | if let navigationViewController = presentedViewController as? UINavigationController { 48 | if let visibleViewController = navigationViewController.visibleViewController { 49 | return visibleViewController.topMostViewController() 50 | } else { 51 | return navigationViewController 52 | } 53 | } 54 | if let tabBarViewController = presentedViewController as? UITabBarController { 55 | if let selectedViewController = tabBarViewController.selectedViewController { 56 | return selectedViewController.topMostViewController() 57 | } 58 | return tabBarViewController.topMostViewController() 59 | } 60 | return presentedViewController!.topMostViewController() 61 | } 62 | } 63 | 64 | extension UIApplication { 65 | func topMostViewController() -> UIViewController? { 66 | UIApplication.shared.windows.filter(\.isKeyWindow).first?.rootViewController?.topMostViewController() 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/ReloadDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ReloadDelegate.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | protocol ReloadDelegate: AnyObject { 16 | func reloadMessage(with id: UUID) 17 | } 18 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Status View/StatusView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // StatusView.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import ChatLayout 14 | import Foundation 15 | import UIKit 16 | 17 | final class StatusView: UIView, StaticViewFactory { 18 | private lazy var imageView = UIImageView(frame: bounds) 19 | 20 | override init(frame: CGRect) { 21 | super.init(frame: frame) 22 | setupSubviews() 23 | } 24 | 25 | required init?(coder: NSCoder) { 26 | super.init(coder: coder) 27 | setupSubviews() 28 | } 29 | 30 | private func setupSubviews() { 31 | translatesAutoresizingMaskIntoConstraints = false 32 | insetsLayoutMarginsFromSafeArea = false 33 | layoutMargins = .zero 34 | addSubview(imageView) 35 | 36 | imageView.translatesAutoresizingMaskIntoConstraints = false 37 | NSLayoutConstraint.activate([ 38 | imageView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 39 | imageView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), 40 | imageView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), 41 | imageView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor) 42 | ]) 43 | let widthConstraint = imageView.widthAnchor.constraint(equalToConstant: 15) 44 | widthConstraint.priority = UILayoutPriority(rawValue: 999) 45 | widthConstraint.isActive = true 46 | let heightConstraint = imageView.heightAnchor.constraint(equalToConstant: 15) 47 | heightConstraint.priority = UILayoutPriority(rawValue: 999) 48 | heightConstraint.isActive = true 49 | 50 | imageView.contentMode = .center 51 | } 52 | 53 | func setup(with status: MessageStatus) { 54 | switch status { 55 | case .sent: 56 | imageView.image = UIImage(named: "sent_status") 57 | imageView.tintColor = .lightGray 58 | case .received: 59 | imageView.image = UIImage(named: "sent_status") 60 | imageView.tintColor = .systemBlue 61 | case .read: 62 | imageView.image = UIImage(named: "read_status") 63 | imageView.tintColor = .systemBlue 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/Text Message View/TextMessageController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // TextMessageController.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | final class TextMessageController { 16 | weak var view: TextMessageView? { 17 | didSet { 18 | view?.reloadData() 19 | } 20 | } 21 | 22 | let text: String 23 | 24 | let type: MessageType 25 | 26 | private let bubbleController: BubbleController 27 | 28 | init(text: String, type: MessageType, bubbleController: BubbleController) { 29 | self.text = text 30 | self.type = type 31 | self.bubbleController = bubbleController 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/URL View/URLController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // URLController.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | import LinkPresentation 15 | 16 | @available(iOS 13, *) 17 | final class URLController { 18 | let url: URL 19 | 20 | var metadata: LPLinkMetadata? 21 | 22 | weak var delegate: ReloadDelegate? 23 | 24 | weak var view: URLView? { 25 | didSet { 26 | UIView.performWithoutAnimation { 27 | view?.reloadData() 28 | } 29 | } 30 | } 31 | 32 | private let provider = LPMetadataProvider() 33 | 34 | private let messageId: UUID 35 | 36 | private let bubbleController: BubbleController 37 | 38 | init(url: URL, messageId: UUID, bubbleController: BubbleController) { 39 | self.url = url 40 | self.messageId = messageId 41 | self.bubbleController = bubbleController 42 | startFetchingMetadata() 43 | } 44 | 45 | private func startFetchingMetadata() { 46 | if let metadata = try? metadataCache.getEntity(for: url) { 47 | self.metadata = metadata 48 | view?.reloadData() 49 | } else { 50 | provider.startFetchingMetadata(for: url) { [weak self] metadata, error in 51 | guard let self, 52 | let metadata, 53 | error == nil else { 54 | return 55 | } 56 | 57 | try? metadataCache.store(entity: metadata, for: url) 58 | 59 | DispatchQueue.main.async { [weak self] in 60 | guard let self else { 61 | return 62 | } 63 | if #available(iOS 16.0, *), 64 | enableSelfSizingSupport { 65 | self.metadata = metadata 66 | view?.reloadData() 67 | } else { 68 | delegate?.reloadMessage(with: messageId) 69 | } 70 | } 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Example/ChatLayout/Chat/View/URL View/URLSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // URLSource.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | struct URLSource: Hashable { 16 | let url: URL 17 | 18 | var isPresentLocally: Bool { 19 | if #available(iOS 13, *) { 20 | metadataCache.isEntityCached(for: url) 21 | } else { 22 | true 23 | } 24 | } 25 | 26 | func hash(into hasher: inout Hasher) { 27 | hasher.combine(url) 28 | hasher.combine(isPresentLocally) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Example/ChatLayout/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | ChatLayout Example 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | UIApplicationSceneManifest 26 | 27 | UIApplicationSupportsMultipleScenes 28 | 29 | UISceneConfigurations 30 | 31 | UIWindowSceneSessionRoleApplication 32 | 33 | 34 | UISceneConfigurationName 35 | Default Configuration 36 | UISceneDelegateClassName 37 | $(PRODUCT_MODULE_NAME).SceneDelegate 38 | 39 | 40 | 41 | 42 | UILaunchStoryboardName 43 | LaunchScreen 44 | UIRequiredDeviceCapabilities 45 | 46 | armv7 47 | 48 | UISupportedInterfaceOrientations 49 | 50 | UIInterfaceOrientationPortrait 51 | UIInterfaceOrientationLandscapeLeft 52 | UIInterfaceOrientationLandscapeRight 53 | 54 | UISupportedInterfaceOrientations~ipad 55 | 56 | UIInterfaceOrientationPortrait 57 | UIInterfaceOrientationPortraitUpsideDown 58 | UIInterfaceOrientationLandscapeLeft 59 | UIInterfaceOrientationLandscapeRight 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /Example/ChatLayout/ProcessInfo+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // ProcessInfo+Extension.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import Foundation 14 | 15 | extension ProcessInfo { 16 | static var isRunningTests: Bool { 17 | processInfo.environment["XCTestConfigurationFilePath"] != nil 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Example/ChatLayout/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // SceneDelegate.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | import UIKit 14 | 15 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 16 | var window: UIWindow? 17 | 18 | @available(iOS 13.0, *) 19 | func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) { 20 | guard !ProcessInfo.isRunningTests else { 21 | return 22 | } 23 | 24 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 25 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 26 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 27 | guard let windowScene = (scene as? UIWindowScene) else { 28 | return 29 | } 30 | 31 | let chatViewController = ChatViewControllerBuilder().build() 32 | let viewController = UINavigationController(rootViewController: chatViewController) 33 | 34 | let window = UIWindow(windowScene: windowScene) 35 | window.rootViewController = viewController 36 | self.window = window 37 | window.makeKeyAndVisible() 38 | } 39 | 40 | @available(iOS 13.0, *) 41 | func sceneDidDisconnect(_: UIScene) { 42 | // Called as the scene is being released by the system. 43 | // This occurs shortly after the scene enters the background, or when its session is discarded. 44 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 45 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 46 | } 47 | 48 | @available(iOS 13.0, *) 49 | func sceneDidBecomeActive(_: UIScene) { 50 | // Called when the scene has moved from an inactive state to an active state. 51 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 52 | } 53 | 54 | @available(iOS 13.0, *) 55 | func sceneWillResignActive(_: UIScene) { 56 | // Called when the scene will move from an active state to an inactive state. 57 | // This may occur due to temporary interruptions (ex. an incoming phone call). 58 | } 59 | 60 | @available(iOS 13.0, *) 61 | func sceneWillEnterForeground(_: UIScene) { 62 | // Called as the scene transitions from the background to the foreground. 63 | // Use this method to undo the changes made on entering the background. 64 | } 65 | 66 | @available(iOS 13.0, *) 67 | func sceneDidEnterBackground(_: UIScene) { 68 | // Called as the scene transitions from the foreground to the background. 69 | // Use this method to save data, release shared resources, and store enough scene-specific state information 70 | // to restore the scene back to its current state. 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Example/ChatLayout_Example.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Example/Podfile: -------------------------------------------------------------------------------- 1 | platform :ios, '12.0' 2 | use_frameworks! 3 | 4 | target 'ChatLayout_Example' do 5 | pod 'ChatLayout', :path => '../' 6 | pod 'InputBarAccessoryView' 7 | pod 'DifferenceKit' 8 | pod 'FPSCounter', '~> 4.0' 9 | 10 | target 'ChatLayout_Tests' do 11 | inherit! :search_paths 12 | 13 | 14 | end 15 | end 16 | 17 | post_install do |installer| 18 | installer.generated_projects.each do |project| 19 | project.targets.each do |target| 20 | target.build_configurations.each do |config| 21 | config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' 22 | end 23 | end 24 | end 25 | end -------------------------------------------------------------------------------- /Example/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - ChatLayout (2.0.13): 3 | - ChatLayout/Ultimate (= 2.0.13) 4 | - ChatLayout/Core (2.0.13) 5 | - ChatLayout/Extras (2.0.13): 6 | - ChatLayout/Core 7 | - ChatLayout/Ultimate (2.0.13): 8 | - ChatLayout/Core 9 | - ChatLayout/Extras 10 | - DifferenceKit (1.3.0): 11 | - DifferenceKit/Core (= 1.3.0) 12 | - DifferenceKit/UIKitExtension (= 1.3.0) 13 | - DifferenceKit/Core (1.3.0) 14 | - DifferenceKit/UIKitExtension (1.3.0): 15 | - DifferenceKit/Core 16 | - FPSCounter (4.1.0) 17 | - InputBarAccessoryView (5.5.0): 18 | - InputBarAccessoryView/Core (= 5.5.0) 19 | - InputBarAccessoryView/Core (5.5.0) 20 | 21 | DEPENDENCIES: 22 | - ChatLayout (from `../`) 23 | - DifferenceKit 24 | - FPSCounter (~> 4.0) 25 | - InputBarAccessoryView 26 | 27 | SPEC REPOS: 28 | trunk: 29 | - DifferenceKit 30 | - FPSCounter 31 | - InputBarAccessoryView 32 | 33 | EXTERNAL SOURCES: 34 | ChatLayout: 35 | :path: "../" 36 | 37 | SPEC CHECKSUMS: 38 | ChatLayout: a2ed626da75cb824b7f14f28215c6717cd8cb108 39 | DifferenceKit: ab185c4d7f9cef8af3fcf593e5b387fb81e999ca 40 | FPSCounter: 884afec377de66637808c4f52ecc3b85a404732b 41 | InputBarAccessoryView: 1d7b0a672b36e370f01f264b3907ef39d03328e3 42 | 43 | PODFILE CHECKSUM: 0bbac6c60b293f3e90bba25beda75886a6fbcb05 44 | 45 | COCOAPODS: 1.15.2 46 | -------------------------------------------------------------------------------- /Example/Tests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /Example/Tests/MockUICollectionViewUpdateItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatLayout 3 | // MockUICollectionViewUpdateItem.swift 4 | // https://github.com/ekazaev/ChatLayout 5 | // 6 | // Created by Eugene Kazaev in 2020-2025. 7 | // Distributed under the MIT license. 8 | // 9 | // Become a sponsor: 10 | // https://github.com/sponsors/ekazaev 11 | // 12 | 13 | @testable import ChatLayout 14 | import Foundation 15 | import UIKit 16 | 17 | class MockUICollectionViewUpdateItem: UICollectionViewUpdateItem { 18 | // swiftlint:disable identifier_name 19 | var _indexPathBeforeUpdate: IndexPath? 20 | var _indexPathAfterUpdate: IndexPath? 21 | var _updateAction: Action 22 | // swiftlint:enable identifier_name 23 | 24 | init(indexPathBeforeUpdate: IndexPath?, indexPathAfterUpdate: IndexPath?, action: Action) { 25 | _indexPathBeforeUpdate = indexPathBeforeUpdate 26 | _indexPathAfterUpdate = indexPathAfterUpdate 27 | _updateAction = action 28 | super.init() 29 | } 30 | 31 | override var indexPathBeforeUpdate: IndexPath? { 32 | _indexPathBeforeUpdate 33 | } 34 | 35 | override var indexPathAfterUpdate: IndexPath? { 36 | _indexPathAfterUpdate 37 | } 38 | 39 | override var updateAction: Action { 40 | _updateAction 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "slather", "2.7.4" 4 | gem "cocoapods" 5 | gem "jazzy" -------------------------------------------------------------------------------- /Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Eugene Kazaev 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.8 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "ChatLayout", 7 | platforms: [ 8 | .iOS(.v12) 9 | ], 10 | products: [ 11 | .library( 12 | name: "ChatLayout", 13 | targets: ["ChatLayout"] 14 | ), 15 | .library(name: "ChatLayoutStatic", 16 | type: .static, 17 | targets: ["ChatLayout"]), 18 | .library(name: "ChatLayoutDynamic", 19 | type: .dynamic, 20 | targets: ["ChatLayout"]) 21 | ], 22 | targets: [ 23 | .target( 24 | name: "ChatLayout", 25 | dependencies: [], 26 | path: "ChatLayout/Classes" 27 | ), 28 | .testTarget( 29 | name: "ChatLayoutTests", 30 | dependencies: ["ChatLayout"], 31 | path: "Example/Tests" 32 | ) 33 | ], 34 | swiftLanguageVersions: [.v5] 35 | ) 36 | -------------------------------------------------------------------------------- /_Pods.xcodeproj: -------------------------------------------------------------------------------- 1 | Example/Pods/Pods.xcodeproj -------------------------------------------------------------------------------- /docs/badge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | documentation 17 | 18 | 19 | documentation 20 | 21 | 22 | 100% 23 | 24 | 25 | 100% 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /docs/css/highlight.css: -------------------------------------------------------------------------------- 1 | /*! Jazzy - https://github.com/realm/jazzy 2 | * Copyright Realm Inc. 3 | * SPDX-License-Identifier: MIT 4 | */ 5 | /* Credit to https://gist.github.com/wataru420/2048287 */ 6 | .highlight .c { 7 | color: #999988; 8 | font-style: italic; } 9 | 10 | .highlight .err { 11 | color: #a61717; 12 | background-color: #e3d2d2; } 13 | 14 | .highlight .k { 15 | color: #000000; 16 | font-weight: bold; } 17 | 18 | .highlight .o { 19 | color: #000000; 20 | font-weight: bold; } 21 | 22 | .highlight .cm { 23 | color: #999988; 24 | font-style: italic; } 25 | 26 | .highlight .cp { 27 | color: #999999; 28 | font-weight: bold; } 29 | 30 | .highlight .c1 { 31 | color: #999988; 32 | font-style: italic; } 33 | 34 | .highlight .cs { 35 | color: #999999; 36 | font-weight: bold; 37 | font-style: italic; } 38 | 39 | .highlight .gd { 40 | color: #000000; 41 | background-color: #ffdddd; } 42 | 43 | .highlight .gd .x { 44 | color: #000000; 45 | background-color: #ffaaaa; } 46 | 47 | .highlight .ge { 48 | color: #000000; 49 | font-style: italic; } 50 | 51 | .highlight .gr { 52 | color: #aa0000; } 53 | 54 | .highlight .gh { 55 | color: #999999; } 56 | 57 | .highlight .gi { 58 | color: #000000; 59 | background-color: #ddffdd; } 60 | 61 | .highlight .gi .x { 62 | color: #000000; 63 | background-color: #aaffaa; } 64 | 65 | .highlight .go { 66 | color: #888888; } 67 | 68 | .highlight .gp { 69 | color: #555555; } 70 | 71 | .highlight .gs { 72 | font-weight: bold; } 73 | 74 | .highlight .gu { 75 | color: #aaaaaa; } 76 | 77 | .highlight .gt { 78 | color: #aa0000; } 79 | 80 | .highlight .kc { 81 | color: #000000; 82 | font-weight: bold; } 83 | 84 | .highlight .kd { 85 | color: #000000; 86 | font-weight: bold; } 87 | 88 | .highlight .kp { 89 | color: #000000; 90 | font-weight: bold; } 91 | 92 | .highlight .kr { 93 | color: #000000; 94 | font-weight: bold; } 95 | 96 | .highlight .kt { 97 | color: #445588; } 98 | 99 | .highlight .m { 100 | color: #009999; } 101 | 102 | .highlight .s { 103 | color: #d14; } 104 | 105 | .highlight .na { 106 | color: #008080; } 107 | 108 | .highlight .nb { 109 | color: #0086B3; } 110 | 111 | .highlight .nc { 112 | color: #445588; 113 | font-weight: bold; } 114 | 115 | .highlight .no { 116 | color: #008080; } 117 | 118 | .highlight .ni { 119 | color: #800080; } 120 | 121 | .highlight .ne { 122 | color: #990000; 123 | font-weight: bold; } 124 | 125 | .highlight .nf { 126 | color: #990000; } 127 | 128 | .highlight .nn { 129 | color: #555555; } 130 | 131 | .highlight .nt { 132 | color: #000080; } 133 | 134 | .highlight .nv { 135 | color: #008080; } 136 | 137 | .highlight .ow { 138 | color: #000000; 139 | font-weight: bold; } 140 | 141 | .highlight .w { 142 | color: #bbbbbb; } 143 | 144 | .highlight .mf { 145 | color: #009999; } 146 | 147 | .highlight .mh { 148 | color: #009999; } 149 | 150 | .highlight .mi { 151 | color: #009999; } 152 | 153 | .highlight .mo { 154 | color: #009999; } 155 | 156 | .highlight .sb { 157 | color: #d14; } 158 | 159 | .highlight .sc { 160 | color: #d14; } 161 | 162 | .highlight .sd { 163 | color: #d14; } 164 | 165 | .highlight .s2 { 166 | color: #d14; } 167 | 168 | .highlight .se { 169 | color: #d14; } 170 | 171 | .highlight .sh { 172 | color: #d14; } 173 | 174 | .highlight .si { 175 | color: #d14; } 176 | 177 | .highlight .sx { 178 | color: #d14; } 179 | 180 | .highlight .sr { 181 | color: #009926; } 182 | 183 | .highlight .s1 { 184 | color: #d14; } 185 | 186 | .highlight .ss { 187 | color: #990073; } 188 | 189 | .highlight .bp { 190 | color: #999999; } 191 | 192 | .highlight .vc { 193 | color: #008080; } 194 | 195 | .highlight .vg { 196 | color: #008080; } 197 | 198 | .highlight .vi { 199 | color: #008080; } 200 | 201 | .highlight .il { 202 | color: #009999; } 203 | -------------------------------------------------------------------------------- /docs/docsets/ChatLayout.docset/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleIdentifier 6 | com.jazzy.chatlayout 7 | CFBundleName 8 | ChatLayout 9 | DocSetPlatformFamily 10 | chatlayout 11 | isDashDocset 12 | 13 | dashIndexFilePath 14 | index.html 15 | isJavaScriptEnabled 16 | 17 | DashDocSetFamily 18 | dashtoc 19 | 20 | 21 | -------------------------------------------------------------------------------- /docs/docsets/ChatLayout.docset/Contents/Resources/Documents/css/highlight.css: -------------------------------------------------------------------------------- 1 | /*! Jazzy - https://github.com/realm/jazzy 2 | * Copyright Realm Inc. 3 | * SPDX-License-Identifier: MIT 4 | */ 5 | /* Credit to https://gist.github.com/wataru420/2048287 */ 6 | .highlight .c { 7 | color: #999988; 8 | font-style: italic; } 9 | 10 | .highlight .err { 11 | color: #a61717; 12 | background-color: #e3d2d2; } 13 | 14 | .highlight .k { 15 | color: #000000; 16 | font-weight: bold; } 17 | 18 | .highlight .o { 19 | color: #000000; 20 | font-weight: bold; } 21 | 22 | .highlight .cm { 23 | color: #999988; 24 | font-style: italic; } 25 | 26 | .highlight .cp { 27 | color: #999999; 28 | font-weight: bold; } 29 | 30 | .highlight .c1 { 31 | color: #999988; 32 | font-style: italic; } 33 | 34 | .highlight .cs { 35 | color: #999999; 36 | font-weight: bold; 37 | font-style: italic; } 38 | 39 | .highlight .gd { 40 | color: #000000; 41 | background-color: #ffdddd; } 42 | 43 | .highlight .gd .x { 44 | color: #000000; 45 | background-color: #ffaaaa; } 46 | 47 | .highlight .ge { 48 | color: #000000; 49 | font-style: italic; } 50 | 51 | .highlight .gr { 52 | color: #aa0000; } 53 | 54 | .highlight .gh { 55 | color: #999999; } 56 | 57 | .highlight .gi { 58 | color: #000000; 59 | background-color: #ddffdd; } 60 | 61 | .highlight .gi .x { 62 | color: #000000; 63 | background-color: #aaffaa; } 64 | 65 | .highlight .go { 66 | color: #888888; } 67 | 68 | .highlight .gp { 69 | color: #555555; } 70 | 71 | .highlight .gs { 72 | font-weight: bold; } 73 | 74 | .highlight .gu { 75 | color: #aaaaaa; } 76 | 77 | .highlight .gt { 78 | color: #aa0000; } 79 | 80 | .highlight .kc { 81 | color: #000000; 82 | font-weight: bold; } 83 | 84 | .highlight .kd { 85 | color: #000000; 86 | font-weight: bold; } 87 | 88 | .highlight .kp { 89 | color: #000000; 90 | font-weight: bold; } 91 | 92 | .highlight .kr { 93 | color: #000000; 94 | font-weight: bold; } 95 | 96 | .highlight .kt { 97 | color: #445588; } 98 | 99 | .highlight .m { 100 | color: #009999; } 101 | 102 | .highlight .s { 103 | color: #d14; } 104 | 105 | .highlight .na { 106 | color: #008080; } 107 | 108 | .highlight .nb { 109 | color: #0086B3; } 110 | 111 | .highlight .nc { 112 | color: #445588; 113 | font-weight: bold; } 114 | 115 | .highlight .no { 116 | color: #008080; } 117 | 118 | .highlight .ni { 119 | color: #800080; } 120 | 121 | .highlight .ne { 122 | color: #990000; 123 | font-weight: bold; } 124 | 125 | .highlight .nf { 126 | color: #990000; } 127 | 128 | .highlight .nn { 129 | color: #555555; } 130 | 131 | .highlight .nt { 132 | color: #000080; } 133 | 134 | .highlight .nv { 135 | color: #008080; } 136 | 137 | .highlight .ow { 138 | color: #000000; 139 | font-weight: bold; } 140 | 141 | .highlight .w { 142 | color: #bbbbbb; } 143 | 144 | .highlight .mf { 145 | color: #009999; } 146 | 147 | .highlight .mh { 148 | color: #009999; } 149 | 150 | .highlight .mi { 151 | color: #009999; } 152 | 153 | .highlight .mo { 154 | color: #009999; } 155 | 156 | .highlight .sb { 157 | color: #d14; } 158 | 159 | .highlight .sc { 160 | color: #d14; } 161 | 162 | .highlight .sd { 163 | color: #d14; } 164 | 165 | .highlight .s2 { 166 | color: #d14; } 167 | 168 | .highlight .se { 169 | color: #d14; } 170 | 171 | .highlight .sh { 172 | color: #d14; } 173 | 174 | .highlight .si { 175 | color: #d14; } 176 | 177 | .highlight .sx { 178 | color: #d14; } 179 | 180 | .highlight .sr { 181 | color: #009926; } 182 | 183 | .highlight .s1 { 184 | color: #d14; } 185 | 186 | .highlight .ss { 187 | color: #990073; } 188 | 189 | .highlight .bp { 190 | color: #999999; } 191 | 192 | .highlight .vc { 193 | color: #008080; } 194 | 195 | .highlight .vg { 196 | color: #008080; } 197 | 198 | .highlight .vi { 199 | color: #008080; } 200 | 201 | .highlight .il { 202 | color: #009999; } 203 | -------------------------------------------------------------------------------- /docs/docsets/ChatLayout.docset/Contents/Resources/Documents/img/carat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/docs/docsets/ChatLayout.docset/Contents/Resources/Documents/img/carat.png -------------------------------------------------------------------------------- /docs/docsets/ChatLayout.docset/Contents/Resources/Documents/img/dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/docs/docsets/ChatLayout.docset/Contents/Resources/Documents/img/dash.png -------------------------------------------------------------------------------- /docs/docsets/ChatLayout.docset/Contents/Resources/Documents/img/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/docs/docsets/ChatLayout.docset/Contents/Resources/Documents/img/spinner.gif -------------------------------------------------------------------------------- /docs/docsets/ChatLayout.docset/Contents/Resources/Documents/js/jazzy.js: -------------------------------------------------------------------------------- 1 | // Jazzy - https://github.com/realm/jazzy 2 | // Copyright Realm Inc. 3 | // SPDX-License-Identifier: MIT 4 | 5 | window.jazzy = {'docset': false} 6 | if (typeof window.dash != 'undefined') { 7 | document.documentElement.className += ' dash' 8 | window.jazzy.docset = true 9 | } 10 | if (navigator.userAgent.match(/xcode/i)) { 11 | document.documentElement.className += ' xcode' 12 | window.jazzy.docset = true 13 | } 14 | 15 | function toggleItem($link, $content) { 16 | var animationDuration = 300; 17 | $link.toggleClass('token-open'); 18 | $content.slideToggle(animationDuration); 19 | } 20 | 21 | function itemLinkToContent($link) { 22 | return $link.parent().parent().next(); 23 | } 24 | 25 | // On doc load + hash-change, open any targetted item 26 | function openCurrentItemIfClosed() { 27 | if (window.jazzy.docset) { 28 | return; 29 | } 30 | var $link = $(`a[name="${location.hash.substring(1)}"]`).nextAll('.token'); 31 | $content = itemLinkToContent($link); 32 | if ($content.is(':hidden')) { 33 | toggleItem($link, $content); 34 | } 35 | } 36 | 37 | $(openCurrentItemIfClosed); 38 | $(window).on('hashchange', openCurrentItemIfClosed); 39 | 40 | // On item link ('token') click, toggle its discussion 41 | $('.token').on('click', function(event) { 42 | if (window.jazzy.docset) { 43 | return; 44 | } 45 | var $link = $(this); 46 | toggleItem($link, itemLinkToContent($link)); 47 | 48 | // Keeps the document from jumping to the hash. 49 | var href = $link.attr('href'); 50 | if (history.pushState) { 51 | history.pushState({}, '', href); 52 | } else { 53 | location.hash = href; 54 | } 55 | event.preventDefault(); 56 | }); 57 | 58 | // Clicks on links to the current, closed, item need to open the item 59 | $("a:not('.token')").on('click', function() { 60 | if (location == this.href) { 61 | openCurrentItemIfClosed(); 62 | } 63 | }); 64 | 65 | // KaTeX rendering 66 | if ("katex" in window) { 67 | $($('.math').each( (_, element) => { 68 | katex.render(element.textContent, element, { 69 | displayMode: $(element).hasClass('m-block'), 70 | throwOnError: false, 71 | trust: true 72 | }); 73 | })) 74 | } 75 | -------------------------------------------------------------------------------- /docs/docsets/ChatLayout.docset/Contents/Resources/Documents/js/jazzy.search.js: -------------------------------------------------------------------------------- 1 | // Jazzy - https://github.com/realm/jazzy 2 | // Copyright Realm Inc. 3 | // SPDX-License-Identifier: MIT 4 | 5 | $(function(){ 6 | var $typeahead = $('[data-typeahead]'); 7 | var $form = $typeahead.parents('form'); 8 | var searchURL = $form.attr('action'); 9 | 10 | function displayTemplate(result) { 11 | return result.name; 12 | } 13 | 14 | function suggestionTemplate(result) { 15 | var t = '
'; 16 | t += '' + result.name + ''; 17 | if (result.parent_name) { 18 | t += '' + result.parent_name + ''; 19 | } 20 | t += '
'; 21 | return t; 22 | } 23 | 24 | $typeahead.one('focus', function() { 25 | $form.addClass('loading'); 26 | 27 | $.getJSON(searchURL).then(function(searchData) { 28 | const searchIndex = lunr(function() { 29 | this.ref('url'); 30 | this.field('name'); 31 | this.field('abstract'); 32 | for (const [url, doc] of Object.entries(searchData)) { 33 | this.add({url: url, name: doc.name, abstract: doc.abstract}); 34 | } 35 | }); 36 | 37 | $typeahead.typeahead( 38 | { 39 | highlight: true, 40 | minLength: 3, 41 | autoselect: true 42 | }, 43 | { 44 | limit: 10, 45 | display: displayTemplate, 46 | templates: { suggestion: suggestionTemplate }, 47 | source: function(query, sync) { 48 | const lcSearch = query.toLowerCase(); 49 | const results = searchIndex.query(function(q) { 50 | q.term(lcSearch, { boost: 100 }); 51 | q.term(lcSearch, { 52 | boost: 10, 53 | wildcard: lunr.Query.wildcard.TRAILING 54 | }); 55 | }).map(function(result) { 56 | var doc = searchData[result.ref]; 57 | doc.url = result.ref; 58 | return doc; 59 | }); 60 | sync(results); 61 | } 62 | } 63 | ); 64 | $form.removeClass('loading'); 65 | $typeahead.trigger('focus'); 66 | }); 67 | }); 68 | 69 | var baseURL = searchURL.slice(0, -"search.json".length); 70 | 71 | $typeahead.on('typeahead:select', function(e, result) { 72 | window.location = baseURL + result.url; 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /docs/docsets/ChatLayout.docset/Contents/Resources/docSet.dsidx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/docs/docsets/ChatLayout.docset/Contents/Resources/docSet.dsidx -------------------------------------------------------------------------------- /docs/docsets/ChatLayout.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/docs/docsets/ChatLayout.tgz -------------------------------------------------------------------------------- /docs/img/carat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/docs/img/carat.png -------------------------------------------------------------------------------- /docs/img/dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/docs/img/dash.png -------------------------------------------------------------------------------- /docs/img/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/docs/img/spinner.gif -------------------------------------------------------------------------------- /docs/js/jazzy.js: -------------------------------------------------------------------------------- 1 | // Jazzy - https://github.com/realm/jazzy 2 | // Copyright Realm Inc. 3 | // SPDX-License-Identifier: MIT 4 | 5 | window.jazzy = {'docset': false} 6 | if (typeof window.dash != 'undefined') { 7 | document.documentElement.className += ' dash' 8 | window.jazzy.docset = true 9 | } 10 | if (navigator.userAgent.match(/xcode/i)) { 11 | document.documentElement.className += ' xcode' 12 | window.jazzy.docset = true 13 | } 14 | 15 | function toggleItem($link, $content) { 16 | var animationDuration = 300; 17 | $link.toggleClass('token-open'); 18 | $content.slideToggle(animationDuration); 19 | } 20 | 21 | function itemLinkToContent($link) { 22 | return $link.parent().parent().next(); 23 | } 24 | 25 | // On doc load + hash-change, open any targetted item 26 | function openCurrentItemIfClosed() { 27 | if (window.jazzy.docset) { 28 | return; 29 | } 30 | var $link = $(`a[name="${location.hash.substring(1)}"]`).nextAll('.token'); 31 | $content = itemLinkToContent($link); 32 | if ($content.is(':hidden')) { 33 | toggleItem($link, $content); 34 | } 35 | } 36 | 37 | $(openCurrentItemIfClosed); 38 | $(window).on('hashchange', openCurrentItemIfClosed); 39 | 40 | // On item link ('token') click, toggle its discussion 41 | $('.token').on('click', function(event) { 42 | if (window.jazzy.docset) { 43 | return; 44 | } 45 | var $link = $(this); 46 | toggleItem($link, itemLinkToContent($link)); 47 | 48 | // Keeps the document from jumping to the hash. 49 | var href = $link.attr('href'); 50 | if (history.pushState) { 51 | history.pushState({}, '', href); 52 | } else { 53 | location.hash = href; 54 | } 55 | event.preventDefault(); 56 | }); 57 | 58 | // Clicks on links to the current, closed, item need to open the item 59 | $("a:not('.token')").on('click', function() { 60 | if (location == this.href) { 61 | openCurrentItemIfClosed(); 62 | } 63 | }); 64 | 65 | // KaTeX rendering 66 | if ("katex" in window) { 67 | $($('.math').each( (_, element) => { 68 | katex.render(element.textContent, element, { 69 | displayMode: $(element).hasClass('m-block'), 70 | throwOnError: false, 71 | trust: true 72 | }); 73 | })) 74 | } 75 | -------------------------------------------------------------------------------- /docs/js/jazzy.search.js: -------------------------------------------------------------------------------- 1 | // Jazzy - https://github.com/realm/jazzy 2 | // Copyright Realm Inc. 3 | // SPDX-License-Identifier: MIT 4 | 5 | $(function(){ 6 | var $typeahead = $('[data-typeahead]'); 7 | var $form = $typeahead.parents('form'); 8 | var searchURL = $form.attr('action'); 9 | 10 | function displayTemplate(result) { 11 | return result.name; 12 | } 13 | 14 | function suggestionTemplate(result) { 15 | var t = '
'; 16 | t += '' + result.name + ''; 17 | if (result.parent_name) { 18 | t += '' + result.parent_name + ''; 19 | } 20 | t += '
'; 21 | return t; 22 | } 23 | 24 | $typeahead.one('focus', function() { 25 | $form.addClass('loading'); 26 | 27 | $.getJSON(searchURL).then(function(searchData) { 28 | const searchIndex = lunr(function() { 29 | this.ref('url'); 30 | this.field('name'); 31 | this.field('abstract'); 32 | for (const [url, doc] of Object.entries(searchData)) { 33 | this.add({url: url, name: doc.name, abstract: doc.abstract}); 34 | } 35 | }); 36 | 37 | $typeahead.typeahead( 38 | { 39 | highlight: true, 40 | minLength: 3, 41 | autoselect: true 42 | }, 43 | { 44 | limit: 10, 45 | display: displayTemplate, 46 | templates: { suggestion: suggestionTemplate }, 47 | source: function(query, sync) { 48 | const lcSearch = query.toLowerCase(); 49 | const results = searchIndex.query(function(q) { 50 | q.term(lcSearch, { boost: 100 }); 51 | q.term(lcSearch, { 52 | boost: 10, 53 | wildcard: lunr.Query.wildcard.TRAILING 54 | }); 55 | }).map(function(result) { 56 | var doc = searchData[result.ref]; 57 | doc.url = result.ref; 58 | return doc; 59 | }); 60 | sync(results); 61 | } 62 | } 63 | ); 64 | $form.removeClass('loading'); 65 | $typeahead.trigger('focus'); 66 | }); 67 | }); 68 | 69 | var baseURL = searchURL.slice(0, -"search.json".length); 70 | 71 | $typeahead.on('typeahead:select', function(e, result) { 72 | window.location = baseURL + result.url; 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /docs/tests/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekazaev/ChatLayout/0576ac90f9e0f94c9f7acc7983381394544ceb7b/docs/tests/logo.jpg -------------------------------------------------------------------------------- /docs/undocumented.json: -------------------------------------------------------------------------------- 1 | { 2 | "warnings": [ 3 | 4 | ], 5 | "source_directory": "/Users/ekazaev/Workplace/ChatLayout" 6 | } --------------------------------------------------------------------------------