diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..8dc7e75
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,41 @@
+---
+name: "🐛 Bug Report"
+about: Report a reproducible bug or regression.
+title: 'Bug: '
+labels: 'bug'
+
+---
+
+
+
+Application version:
+
+## Steps To Reproduce
+
+1.
+2.
+
+
+
+Link to code example:
+
+
+
+## The current behavior
+
+
+## The expected behavior
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..5b2f57d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,11 @@
+---
+name: 🛠 Feature request
+about: If you have a feature request for the snacker, file it here.
+labels: 'type: enhancement'
+---
+
+**Feature description**
+Clearly and concisely describe the feature.
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
\ No newline at end of file
diff --git a/.github/PULL_REQUEST_TEMPLATE/bug_template.md b/.github/PULL_REQUEST_TEMPLATE/bug_template.md
new file mode 100644
index 0000000..7d6a149
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/bug_template.md
@@ -0,0 +1,9 @@
+## Bug description
+Clearly and concisely describe the problem.
+
+## Solution description
+Describe your code changes in detail for reviewers. Explain the technical solution you have provided and how it fixes the issue case.
+
+## Covered unit test cases
+- [x] yes
+- [x] no
\ No newline at end of file
diff --git a/.github/PULL_REQUEST_TEMPLATE/feature_template.md b/.github/PULL_REQUEST_TEMPLATE/feature_template.md
new file mode 100644
index 0000000..ab3978b
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/feature_template.md
@@ -0,0 +1,12 @@
+## Feature description
+Clearly and concisely describe the feature.
+
+## Solution description
+Describe your code changes in detail for reviewers.
+
+## Areas affected and ensured
+List out the areas affected by your code changes.
+
+## Covered unit test cases
+- [x] yes
+- [x] no
\ No newline at end of file
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..57c6f88
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,34 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+ - package-ecosystem: github-actions
+ directory: /
+ open-pull-requests-limit: 10
+ schedule:
+ interval: daily
+ time: '07:00'
+ timezone: Europe/Berlin
+
+ assignees:
+ - ns-vasilev
+ reviewers:
+ - ns-vasilev
+
+
+ - package-ecosystem: swift
+ directory: /
+ open-pull-requests-limit: 10
+ schedule:
+ interval: daily
+ time: '07:00'
+ timezone: Europe/Berlin
+
+ assignees:
+ - ns-vasilev
+ reviewers:
+ - ns-vasilev
+
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..69db477
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,62 @@
+name: "snacker"
+
+on:
+ push:
+ branches:
+ - main
+ - dev
+ pull_request:
+ paths:
+ - '.swiftlint.yml'
+ - ".github/workflows/**"
+ - "Package.swift"
+ - "Source/**"
+ - "Tests/**"
+
+jobs:
+ SwiftLint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - name: GitHub Action for SwiftLint
+ uses: norio-nomura/action-swiftlint@3.2.1
+ with:
+ args: --strict
+ env:
+ DIFF_BASE: ${{ github.base_ref }}
+ iOS:
+ name: ${{ matrix.name }}
+ runs-on: ${{ matrix.runsOn }}
+ env:
+ DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer"
+ timeout-minutes: 20
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - destination: "OS=18.1,name=iPhone 16 Pro"
+ name: "iOS 18.1"
+ xcode: "Xcode_16.1"
+ runsOn: macOS-14
+ steps:
+ - uses: actions/checkout@v3
+ - name: ${{ matrix.name }}
+ run: xcodebuild test -scheme "Snacker" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1
+ - uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.name }}
+ path: test_output
+
+ discover-typos:
+ name: Discover Typos
+ runs-on: macOS-13
+ env:
+ DEVELOPER_DIR: /Applications/Xcode_14.1.app/Contents/Developer
+ steps:
+ - uses: actions/checkout@v4
+ - name: Discover typos
+ run: |
+ export PATH="$PATH:/Library/Frameworks/Python.framework/Versions/3.11/bin"
+ python3 -m pip install --upgrade pip
+ python3 -m pip install codespell
+ codespell --ignore-words-list="hart,inout,msdos,sur" --skip="./.build/*,./.git/*"
diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml
new file mode 100644
index 0000000..3f63d38
--- /dev/null
+++ b/.github/workflows/danger.yml
@@ -0,0 +1,31 @@
+name: Danger
+
+on:
+ pull_request:
+ types: [synchronize, opened, reopened, labeled, unlabeled, edited]
+
+env:
+ LC_CTYPE: en_US.UTF-8
+ LANG: en_US.UTF-8
+
+jobs:
+ run-danger:
+ runs-on: ubuntu-latest
+ steps:
+ - name: ruby setup
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: 3.1.4
+ bundler-cache: true
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - name: Setup gems
+ run: |
+ gem install bundler
+ bundle install --clean --path vendor/bundle
+ - name: danger
+ env:
+
+ DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
+
+ run: bundle exec danger --verbose
\ No newline at end of file
diff --git a/.swiftformat b/.swiftformat
new file mode 100644
index 0000000..f91ea37
--- /dev/null
+++ b/.swiftformat
@@ -0,0 +1,64 @@
+# Stream rules
+
+--swiftversion 5.3
+
+# Use 'swiftformat --options' to list all of the possible options
+
+--header "\nsnacker\nCopyright © {created.year} Space Code. All rights reserved.\n//"
+
+--enable blankLinesBetweenScopes
+--enable blankLinesAtStartOfScope
+--enable blankLinesAtEndOfScope
+--enable blankLinesAroundMark
+--enable anyObjectProtocol
+--enable consecutiveBlankLines
+--enable consecutiveSpaces
+--enable duplicateImports
+--enable elseOnSameLine
+--enable emptyBraces
+--enable initCoderUnavailable
+--enable leadingDelimiters
+--enable numberFormatting
+--enable preferKeyPath
+--enable redundantBreak
+--enable redundantExtensionACL
+--enable redundantFileprivate
+--enable redundantGet
+--enable redundantInit
+--enable redundantLet
+--enable redundantLetError
+--enable redundantNilInit
+--enable redundantObjc
+--enable redundantParens
+--enable redundantPattern
+--enable redundantRawValues
+--enable redundantReturn
+--enable redundantSelf
+--enable redundantVoidReturnType
+--enable semicolons
+--enable sortImports
+--enable sortSwitchCases
+--enable spaceAroundBraces
+--enable spaceAroundBrackets
+--enable spaceAroundComments
+--enable spaceAroundGenerics
+--enable spaceAroundOperators
+--enable spaceInsideBraces
+--enable spaceInsideBrackets
+--enable spaceInsideComments
+--enable spaceInsideGenerics
+--enable spaceInsideParens
+--enable strongOutlets
+--enable strongifiedSelf
+--enable todos
+--enable trailingClosures
+--enable unusedArguments
+--enable void
+--enable markTypes
+--enable isEmpty
+
+# format options
+
+--wraparguments before-first
+--wrapcollections before-first
+--maxwidth 140
\ No newline at end of file
diff --git a/.swiftlint.yml b/.swiftlint.yml
new file mode 100644
index 0000000..89efd09
--- /dev/null
+++ b/.swiftlint.yml
@@ -0,0 +1,135 @@
+excluded:
+ - Tests
+ - Package.swift
+ - .build
+
+# Rules
+
+disabled_rules:
+ - trailing_comma
+ - todo
+ - opening_brace
+
+opt_in_rules: # some rules are only opt-in
+ - anyobject_protocol
+ - array_init
+ - attributes
+ - closure_body_length
+ - closure_end_indentation
+ - closure_spacing
+ - collection_alignment
+ - conditional_returns_on_newline
+ - contains_over_filter_count
+ - contains_over_filter_is_empty
+ - contains_over_first_not_nil
+ - contains_over_range_nil_comparison
+ - convenience_type
+ - discouraged_object_literal
+ - discouraged_optional_boolean
+ - empty_collection_literal
+ - empty_count
+ - empty_string
+ - empty_xctest_method
+ - enum_case_associated_values_count
+ - explicit_init
+ - fallthrough
+ - fatal_error_message
+ - file_name
+ - file_types_order
+ - first_where
+ - flatmap_over_map_reduce
+ - force_unwrapping
+ - ibinspectable_in_extension
+ - identical_operands
+ - implicit_return
+ - inert_defer
+ - joined_default_parameter
+ - last_where
+ - legacy_multiple
+ - legacy_random
+ - literal_expression_end_indentation
+ - lower_acl_than_parent
+ - multiline_arguments
+ - multiline_function_chains
+ - multiline_literal_brackets
+ - multiline_parameters
+ - multiline_parameters_brackets
+ - no_space_in_method_call
+ - operator_usage_whitespace
+ - optional_enum_case_matching
+ - orphaned_doc_comment
+ - overridden_super_call
+ - override_in_extension
+ - pattern_matching_keywords
+ - prefer_self_type_over_type_of_self
+ - prefer_zero_over_explicit_init
+ - prefixed_toplevel_constant
+ - private_action
+ - prohibited_super_call
+ - quick_discouraged_call
+ - quick_discouraged_focused_test
+ - quick_discouraged_pending_test
+ - reduce_into
+ - redundant_nil_coalescing
+ - redundant_objc_attribute
+ - redundant_type_annotation
+ - required_enum_case
+ - single_test_class
+ - sorted_first_last
+ - sorted_imports
+ - static_operator
+ - strict_fileprivate
+ - switch_case_on_newline
+ - toggle_bool
+ - unavailable_function
+ - unneeded_parentheses_in_closure_argument
+ - unowned_variable_capture
+ - untyped_error_in_catch
+ - vertical_parameter_alignment_on_call
+ - vertical_whitespace_closing_braces
+ - vertical_whitespace_opening_braces
+ - xct_specific_matcher
+ - yoda_condition
+
+force_cast: warning
+force_try: warning
+
+identifier_name:
+ excluded:
+ - id
+ - URL
+
+analyzer_rules:
+ - unused_import
+ - unused_declaration
+
+line_length:
+ warning: 130
+ error: 200
+
+type_body_length:
+ warning: 300
+ error: 400
+
+file_length:
+ warning: 500
+ error: 1200
+
+function_body_length:
+ warning: 30
+ error: 50
+
+large_tuple:
+ error: 3
+
+nesting:
+ type_level:
+ warning: 2
+ statement_level:
+ warning: 10
+
+
+type_name:
+ max_length:
+ warning: 40
+ error: 50
\ No newline at end of file
diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
+## Description
+`snacker` is a lightweight Swift library for displaying snackbars in iOS applications.
+
+- [Usage](#usage)
+- [Requirements](#requirements)
+- [Installation](#installation)
+- [Communication](#communication)
+- [Contributing](#contributing)
+- [Author](#author)
+- [License](#license)
+
+## Usage
+
+```swift
+import Snackner
+
+Snacker.shared.action(
+ .snack(
+ view: view,
+ data: SnackbarData(
+ snackbarAlignment: .top(spacing: 20),
+ insets: .zero,
+ animationDuration: 0.25
+ )
+ ),
+ container: window
+)
+```
+
+## Requirements
+
+- iOS 17.0+
+- Xcode 16.0
+- Swift 6.0
+
+## Installation
+### Swift Package Manager
+
+The [Swift Package Manager](https://swift.org/package-manager/) is a tool for automating the distribution of Swift code and is integrated into the `swift` compiler. It is in early development, but `snacker` does support its use on supported platforms.
+
+Once you have your Swift package set up, adding `snacker` as a dependency is as easy as adding it to the `dependencies` value of your `Package.swift`.
+
+```swift
+dependencies: [
+ .package(url: "https://github.com/space-code/snacker.git", .upToNextMajor(from: "1.0.0"))
+]
+```
+
+## Communication
+- If you **found a bug**, open an issue.
+- If you **have a feature request**, open an issue.
+- If you **want to contribute**, submit a pull request.
+
+## Contributing
+Bootstrapping development environment
+
+```
+make bootstrap
+```
+
+Please feel free to help out with this project! If you see something that could be made better or want a new feature, open up an issue or send a Pull Request!
+
+## Author
+Nikita Vasilev, nv3212@gmail.com
+
+## License
+snacker is available under the MIT license. See the LICENSE file for more info.
diff --git a/SEQURITY.md b/SEQURITY.md
new file mode 100644
index 0000000..20dffca
--- /dev/null
+++ b/SEQURITY.md
@@ -0,0 +1,7 @@
+# Reporting Security Vulnerabilities
+
+This software is built with security and data privacy in mind to ensure your data is safe. We are grateful for security researchers and users reporting a vulnerability to us, first. To ensure that your request is handled in a timely manner and non-disclosure of vulnerabilities can be assured, please follow the below guideline.
+
+**Please do not report security vulnerabilities directly on GitHub. GitHub Issues can be publicly seen and therefore would result in a direct disclosure.**
+
+* Please address questions about data privacy, security concepts, and other media requests to the nv3212@gmail.com mailbox.
\ No newline at end of file
diff --git a/Sources/snacker/Classes/Core/Snacker/Snacker.swift b/Sources/snacker/Classes/Core/Snacker/Snacker.swift
new file mode 100644
index 0000000..153192f
--- /dev/null
+++ b/Sources/snacker/Classes/Core/Snacker/Snacker.swift
@@ -0,0 +1,110 @@
+//
+// snacker
+// Copyright © 2025 Space Code. All rights reserved.
+//
+
+import UIKit
+
+// MARK: - ISnacker
+
+@MainActor
+public protocol ISnacker {
+ func action(_ action: SnackbarAction, container: UIView)
+}
+
+// MARK: - Snacker
+
+@MainActor
+public final class Snacker {
+ // MARK: Properties
+
+ public static let shared: ISnacker = Snacker()
+
+ private var cachedViews: [Int: [SnackbarController]] = [:]
+
+ // MARK: Initialization
+
+ private init() {}
+
+ // MARK: Private
+
+ private func save(controller: SnackbarController, id: Int) {
+ if var views = cachedViews[id] {
+ views.append(controller)
+ cachedViews[id] = views
+ } else {
+ cachedViews[id] = [controller]
+ }
+ }
+
+ private func remove(controller: SnackbarController, id: Int) {
+ controller.hide(animated: true) { [weak self] in
+ guard let index = self?.cachedViews[id]?.firstIndex(of: controller) else {
+ return
+ }
+ self?.cachedViews[id]?.remove(at: index)
+ }
+ }
+
+ private func makeController(
+ for view: UIView,
+ inContainerView containerView: UIView,
+ data: SnackbarData
+ ) -> SnackbarController {
+ let controller = SnackbarController(
+ contentView: view,
+ containerView: containerView,
+ alignment: data.snackbarAlignment,
+ insets: data.insets
+ )
+ return controller
+ }
+}
+
+// MARK: ISnacker
+
+extension Snacker: ISnacker {
+ // swiftlint:disable:next function_body_length
+ public func action(_ action: SnackbarAction, container: UIView) {
+ let hash = container.hash
+
+ switch action {
+ case let .snack(view, data):
+ let controller = makeController(
+ for: view,
+ inContainerView: container,
+ data: data
+ )
+ save(controller: controller, id: hash)
+
+ DispatchQueue.main.async { controller.show(animated: true) }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + data.animationDuration) { [weak self] in
+ self?.remove(controller: controller, id: hash)
+ }
+ case let .snackSingle(view, data) where (cachedViews[hash] ?? []).isEmpty:
+ let controller = makeController(
+ for: view,
+ inContainerView: container,
+ data: data
+ )
+ save(controller: controller, id: hash)
+
+ DispatchQueue.main.async { controller.show(animated: true) }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + data.animationDuration) { [weak self] in
+ self?.remove(controller: controller, id: hash)
+ }
+ case .removeLast:
+ if let controller = cachedViews[hash]?.last {
+ remove(controller: controller, id: hash)
+ }
+ case .removeAll:
+ (cachedViews[hash] ?? []).forEach {
+ remove(controller: $0, id: hash)
+ }
+ default:
+ break
+ }
+ }
+}
diff --git a/Sources/snacker/Classes/Model/SnackbarAction.swift b/Sources/snacker/Classes/Model/SnackbarAction.swift
new file mode 100644
index 0000000..4004cc1
--- /dev/null
+++ b/Sources/snacker/Classes/Model/SnackbarAction.swift
@@ -0,0 +1,13 @@
+//
+// snacker
+// Copyright © 2025 Space Code. All rights reserved.
+//
+
+import UIKit
+
+public enum SnackbarAction {
+ case snack(view: UIView, data: SnackbarData)
+ case snackSingle(view: UIView, data: SnackbarData)
+ case removeLast
+ case removeAll
+}
diff --git a/Sources/snacker/Classes/Model/SnackbarAlignment.swift b/Sources/snacker/Classes/Model/SnackbarAlignment.swift
new file mode 100644
index 0000000..29de4cd
--- /dev/null
+++ b/Sources/snacker/Classes/Model/SnackbarAlignment.swift
@@ -0,0 +1,10 @@
+//
+// snacker
+// Copyright © 2025 Space Code. All rights reserved.
+//
+
+import Foundation
+public enum SnackbarAlignment: Sendable {
+ case top(spacing: CGFloat)
+ case bottom(spacing: CGFloat)
+}
diff --git a/Sources/snacker/Classes/Model/SnackbarData.swift b/Sources/snacker/Classes/Model/SnackbarData.swift
new file mode 100644
index 0000000..5e4e54c
--- /dev/null
+++ b/Sources/snacker/Classes/Model/SnackbarData.swift
@@ -0,0 +1,22 @@
+//
+// snacker
+// Copyright © 2025 Space Code. All rights reserved.
+//
+
+import Foundation
+
+public struct SnackbarData: Sendable {
+ let snackbarAlignment: SnackbarAlignment
+ let insets: SnackbarInsets
+ let animationDuration: TimeInterval
+
+ public init(
+ snackbarAlignment: SnackbarAlignment = .bottom(spacing: 20.0),
+ insets: SnackbarInsets = .zero,
+ animationDuration: TimeInterval = 0.5
+ ) {
+ self.snackbarAlignment = snackbarAlignment
+ self.insets = insets
+ self.animationDuration = animationDuration
+ }
+}
diff --git a/Sources/snacker/Classes/Model/SnackbarInsets.swift b/Sources/snacker/Classes/Model/SnackbarInsets.swift
new file mode 100644
index 0000000..d0cf0e4
--- /dev/null
+++ b/Sources/snacker/Classes/Model/SnackbarInsets.swift
@@ -0,0 +1,18 @@
+//
+// snacker
+// Copyright © 2025 Space Code. All rights reserved.
+//
+
+import Foundation
+
+public struct SnackbarInsets: Sendable {
+ public static let zero = SnackbarInsets(leading: .zero, trailing: .zero)
+
+ let leading: CGFloat
+ let trailing: CGFloat
+
+ public init(leading: CGFloat, trailing: CGFloat) {
+ self.leading = leading
+ self.trailing = trailing
+ }
+}
diff --git a/Sources/snacker/Classes/Presentation/SnackbarController.swift b/Sources/snacker/Classes/Presentation/SnackbarController.swift
new file mode 100644
index 0000000..9382aff
--- /dev/null
+++ b/Sources/snacker/Classes/Presentation/SnackbarController.swift
@@ -0,0 +1,145 @@
+//
+// snacker
+// Copyright © 2025 Space Code. All rights reserved.
+//
+
+import UIKit
+
+// MARK: - SnackbarController
+
+@MainActor
+final class SnackbarController: NSObject {
+ // MARK: Properties
+
+ private let contentView: UIView
+ private weak var containerView: UIView!
+ private let alignment: SnackbarAlignment
+ private let insets: SnackbarInsets
+
+ private var disabledConstraints: [NSLayoutConstraint] = []
+ private var activeConstraints: [NSLayoutConstraint] = []
+
+ // MARK: Initialization
+
+ init(
+ contentView: UIView,
+ containerView: UIView,
+ alignment: SnackbarAlignment,
+ insets: SnackbarInsets
+ ) {
+ self.contentView = contentView
+ self.containerView = containerView
+ self.alignment = alignment
+ self.insets = insets
+ }
+
+ // MARK: Private
+
+ private func staticConstraints() -> [NSLayoutConstraint] {
+ [
+ contentView.leadingAnchor.constraint(greaterThanOrEqualTo: containerView.leadingAnchor, constant: insets.leading),
+ containerView.trailingAnchor.constraint(greaterThanOrEqualTo: contentView.trailingAnchor, constant: insets.trailing),
+ contentView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
+ ]
+ }
+
+ private func activeConstraints(contentView: UIView, container: UIView) -> [NSLayoutConstraint] {
+ switch alignment {
+ case let .top(spacing):
+ [
+ contentView.topAnchor.constraint(
+ equalTo: container.safeAreaLayoutGuide.topAnchor,
+ constant: spacing
+ ),
+ ]
+ case let .bottom(spacing):
+ [
+ contentView.bottomAnchor.constraint(
+ equalTo: container.safeAreaLayoutGuide.bottomAnchor,
+ constant: -1 * spacing
+ ),
+ ]
+ }
+ }
+
+ private func disabledConstraints(contentView: UIView, container: UIView) -> [NSLayoutConstraint] {
+ switch alignment {
+ case .top:
+ [
+ contentView.bottomAnchor.constraint(equalTo: container.topAnchor),
+ ]
+ case .bottom:
+ [
+ contentView.topAnchor.constraint(equalTo: container.bottomAnchor),
+ ]
+ }
+ }
+}
+
+extension SnackbarController {
+ func show(animated: Bool, completion: (() -> Void)? = nil) {
+ contentView.translatesAutoresizingMaskIntoConstraints = false
+ containerView.addSubview(contentView)
+
+ let baseConstraints = staticConstraints()
+
+ disabledConstraints = disabledConstraints(contentView: contentView, container: containerView)
+ activeConstraints = activeConstraints(contentView: contentView, container: containerView)
+
+ NSLayoutConstraint.activate(baseConstraints)
+
+ if animated {
+ NSLayoutConstraint.activate(disabledConstraints)
+ containerView.layoutIfNeeded()
+
+ UIView.animate(
+ withDuration: 0.5,
+ delay: .zero,
+ options: .curveEaseInOut,
+ animations: { [weak self] in
+ guard let self else {
+ return
+ }
+ NSLayoutConstraint.deactivate(self.disabledConstraints)
+ NSLayoutConstraint.activate(self.activeConstraints)
+ self.containerView.layoutIfNeeded()
+ },
+ completion: { _ in
+ completion?()
+ }
+ )
+ } else {
+ NSLayoutConstraint.activate(activeConstraints)
+ completion?()
+ }
+ }
+
+ func hide(animated: Bool, completion: (() -> Void)? = nil) {
+ NSLayoutConstraint.deactivate(activeConstraints)
+ containerView.layoutIfNeeded()
+
+ if animated {
+ UIView.animate(
+ withDuration: 0.5,
+ delay: .zero,
+ options: .curveEaseInOut,
+ animations: { [weak self] in
+ guard let self else {
+ return
+ }
+ NSLayoutConstraint.activate(self.disabledConstraints)
+ self.containerView.layoutIfNeeded()
+ },
+ completion: { [weak self] _ in
+ self?.contentView.removeFromSuperview()
+ self?.containerView = nil
+ completion?()
+ }
+ )
+ } else {
+ contentView.removeFromSuperview()
+ containerView = nil
+ completion?()
+ }
+ }
+}
diff --git a/Sources/snacker/Resources/Media.xcassets/Contents.json b/Sources/snacker/Resources/Media.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/Sources/snacker/Resources/Media.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/snacker/Resources/Media.xcassets/Snack/Contents.json b/Sources/snacker/Resources/Media.xcassets/Snack/Contents.json
new file mode 100644
index 0000000..6e96565
--- /dev/null
+++ b/Sources/snacker/Resources/Media.xcassets/Snack/Contents.json
@@ -0,0 +1,9 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "provides-namespace" : true
+ }
+}
diff --git a/Tests/snackerTests/snackerTests.swift b/Tests/snackerTests/snackerTests.swift
new file mode 100644
index 0000000..eeb82fd
--- /dev/null
+++ b/Tests/snackerTests/snackerTests.swift
@@ -0,0 +1,8 @@
+//
+// snacker
+// Copyright © 2025 Space Code. All rights reserved.
+//
+
+import XCTest
+
+final class snackerTests: XCTestCase {}
diff --git a/hooks/pre-commit b/hooks/pre-commit
new file mode 100755
index 0000000..956fdcb
--- /dev/null
+++ b/hooks/pre-commit
@@ -0,0 +1,38 @@
+#!/bin/bash
+git diff --diff-filter=d --staged --name-only | grep -e '\.swift$' | while read line; do
+ if [[ $line == *"/Generated"* ]]; then
+ echo "IGNORING GENERATED FILE: " "$line";
+ else
+ mint run swiftformat swiftformat "${line}";
+ git add "$line";
+ fi
+done
+
+LINT=$(which mint)
+if [[ -e "${LINT}" ]]; then
+ # Export files in SCRIPT_INPUT_FILE_$count to lint against later
+ count=0
+ while IFS= read -r file_path; do
+ export SCRIPT_INPUT_FILE_$count="$file_path"
+ count=$((count + 1))
+ done < <(git diff --name-only --cached --diff-filter=d | grep ".swift$")
+ export SCRIPT_INPUT_FILE_COUNT=$count
+
+ if [ "$count" -eq 0 ]; then
+ echo "No files to lint!"
+ exit 0
+ fi
+
+ echo "Found $count lintable files! Linting now.."
+ mint run swiftlint --use-script-input-files --strict --config .swiftlint.yml
+ RESULT=$? # swiftline exit value is number of errors
+
+ if [ $RESULT -eq 0 ]; then
+ echo "🎉 Well done. No violation."
+ fi
+ exit $RESULT
+else
+ echo "⚠️ WARNING: SwiftLint not found"
+ echo "⚠️ You might want to edit .git/hooks/pre-commit to locate your swiftlint"
+ exit 0
+fi
\ No newline at end of file