From c001c4c6b897984e018a33ec309ba9d71d36f31b Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Mon, 24 Mar 2025 20:35:42 +0400 Subject: [PATCH] Implement `Snacker` --- .github/workflows/ci.yml | 14 +- .../contents.xcworkspacedata | 7 + Package.swift | 18 +-- README.md | 24 ++- .../Classes/Core/Snacker/Snacker.swift | 110 +++++++++++++ .../Classes/Model/SnackbarAction.swift | 13 ++ .../Classes/Model/SnackbarAlignment.swift | 10 ++ .../snacker/Classes/Model/SnackbarData.swift | 22 +++ .../Classes/Model/SnackbarInsets.swift | 18 +++ .../Presentation/SnackbarController.swift | 145 ++++++++++++++++++ Sources/snacker/Classes/snacker.swift | 6 - .../Resources/Media.xcassets/Contents.json | 6 + .../Media.xcassets/Snack/Contents.json | 9 ++ 13 files changed, 368 insertions(+), 34 deletions(-) create mode 100644 .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 Sources/snacker/Classes/Core/Snacker/Snacker.swift create mode 100644 Sources/snacker/Classes/Model/SnackbarAction.swift create mode 100644 Sources/snacker/Classes/Model/SnackbarAlignment.swift create mode 100644 Sources/snacker/Classes/Model/SnackbarData.swift create mode 100644 Sources/snacker/Classes/Model/SnackbarInsets.swift create mode 100644 Sources/snacker/Classes/Presentation/SnackbarController.swift delete mode 100644 Sources/snacker/Classes/snacker.swift create mode 100644 Sources/snacker/Resources/Media.xcassets/Contents.json create mode 100644 Sources/snacker/Resources/Media.xcassets/Snack/Contents.json 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 @@ CI ## 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 + } +}