diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0f22007..69db477 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,22 +38,10 @@ jobs:
name: "iOS 18.1"
xcode: "Xcode_16.1"
runsOn: macOS-14
- - destination: "OS=17.5,name=iPhone 15 Pro"
- name: "iOS 17.5"
- xcode: "Xcode_15.4"
- runsOn: macOS-14
- - destination: "OS=17.0.1,name=iPhone 14 Pro"
- name: "iOS 17.0.1"
- xcode: "Xcode_15.0"
- runsOn: macos-13
- - destination: "OS=16.4,name=iPhone 14 Pro"
- name: "iOS 16.4"
- xcode: "Xcode_14.3.1"
- runsOn: macos-13
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
+ 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 }}
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 @@
+
+
+
+
+
diff --git a/Package.swift b/Package.swift
index 156036a..7c5b8d0 100644
--- a/Package.swift
+++ b/Package.swift
@@ -4,21 +4,13 @@
import PackageDescription
let package = Package(
- name: "snacker",
+ name: "Snacker",
+ platforms: [.iOS(.v17)],
products: [
- // Products define the executables and libraries a package produces, making them visible to other packages.
- .library(
- name: "snacker",
- targets: ["snacker"]),
+ .library(name: "Snacker", targets: ["Snacker"]),
],
targets: [
- // Targets are the basic building blocks of a package, defining a module or a test suite.
- // Targets can depend on other targets in this package and products from dependencies.
- .target(
- name: "snacker"),
- .testTarget(
- name: "snackerTests",
- dependencies: ["snacker"]
- ),
+ .target(name: "Snacker"),
+ .testTarget(name: "SnackerTests", dependencies: ["Snacker"]),
]
)
diff --git a/README.md b/README.md
index 2b6bf0e..4934766 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
## Description
-`snacker` description.
+`snacker` is a lightweight Swift library for displaying snackbars in iOS applications.
- [Usage](#usage)
- [Requirements](#requirements)
@@ -19,8 +19,28 @@
## 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
@@ -52,4 +72,4 @@ Please feel free to help out with this project! If you see something that could
Nikita Vasilev, nv3212@gmail.com
## License
-snacker is available under the MIT license. See the LICENSE file for more info.
\ No newline at end of file
+snacker is available under the MIT license. See the LICENSE file for more info.
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/Classes/snacker.swift b/Sources/snacker/Classes/snacker.swift
deleted file mode 100644
index c0af11e..0000000
--- a/Sources/snacker/Classes/snacker.swift
+++ /dev/null
@@ -1,6 +0,0 @@
-//
-// snacker
-// Copyright © 2025 Space Code. All rights reserved.
-//
-
-final class snacker {}
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
+ }
+}