├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Resources ├── demo_app.png └── snackbar_demo.gif ├── Sources └── Snackbar │ ├── Constants.swift │ ├── Snackbar.swift │ ├── SnackbarTheme.swift │ ├── SnackbarView.swift │ └── SnackbarWindow.swift └── Tests └── SnackbarTests └── SnackbarTests.swift /.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 | .DS_Store 92 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sapozhnik Ivan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Snackbar", 8 | platforms: [ 9 | .macOS(.v12), 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "Snackbar", 15 | targets: ["Snackbar"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 24 | .target( 25 | name: "Snackbar", 26 | dependencies: []), 27 | .testTarget( 28 | name: "SnackbarTests", 29 | dependencies: ["Snackbar"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Snackbar Component for Cocoa 2 | 3 | [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) 4 | 5 | A lightweight and customizable Snackbar component for Cocoa, designed to display brief informative messages to users. 6 | 7 | ## Demo 8 | 9 | ![](https://github.com/iSapozhnik/Snackbar/blob/main/Resources/snackbar_demo.gif) 10 | 11 | ## Features 12 | 13 | - Display short messages or notifications to users 14 | - Customizable appearance, including background color, text color, and animation duration 15 | - Support for action buttons and callback handlers 16 | - Easy integration into existing Cocoa projects 17 | 18 | ## Installation 19 | 20 | ### Cocoapods 21 | 22 | Swift PAckage manager is your friend. 23 | 24 | 25 | ## Usage 26 | 27 | ### Initialization 28 | 29 | Make a theme 30 | 31 | ```swift 32 | extension SnackbarTheme where Self == DefaultSnackbarTheme { 33 | static var info: SnackbarTheme { DefaultSnackbarTheme(withStyle: .info) } 34 | static var alert: SnackbarTheme { DefaultSnackbarTheme(withStyle: .alert) } 35 | static var warning: SnackbarTheme { DefaultSnackbarTheme(withStyle: .warning) } 36 | static var success: SnackbarTheme { DefaultSnackbarTheme(withStyle: .success) } 37 | } 38 | 39 | struct DefaultSnackbarTheme: SnackbarTheme { 40 | var style: SnackbarStyle 41 | 42 | init(withStyle style: SnackbarStyle) { 43 | self.style = style 44 | } 45 | 46 | var textColor: NSColor { .labelColor } 47 | var backgroundColor: NSColor { 48 | switch style { 49 | case .alert: 50 | return .systemRed 51 | case .success: 52 | return .systemGreen 53 | case .warning: 54 | return .systemOrange 55 | case .info: 56 | return .systemBlue 57 | } 58 | } 59 | 60 | var borderColor: NSColor { 61 | .secondaryLabelColor 62 | } 63 | } 64 | ``` 65 | 66 | ### Displaying a Snackbar 67 | 68 | Snackbar with action buttons and icon. 69 | 70 | ```swift 71 | let theme: SnackbarTheme = .alert 72 | let actions = [ 73 | SnackbarAction( 74 | title: NSLocalizedString("Remove", comment: ""), 75 | icon: nil, 76 | type: .primary, 77 | action: {} 78 | ), 79 | SnackbarAction( 80 | title: NSLocalizedString("Later", comment: ""), 81 | icon: nil, 82 | type: .secondary, 83 | action: {} 84 | ), 85 | ] 86 | Snackbar.show( 87 | theme: theme, 88 | type: .permanent, 89 | title: NSLocalizedString("Are you sure you want to remove all spaces?", comment: "").text, 90 | subtitle: NSLocalizedString("You can not undo this action", comment: "").text, 91 | actions: actions, 92 | actionsLayout: .horizontal, 93 | hasActionsSeparator: false, 94 | icon: NSImage(named: "your_icon"), 95 | fromWindow: view.window 96 | ) 97 | ``` 98 | 99 | ## License 100 | 101 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 102 | 103 | ## Contributing 104 | 105 | Contributions are welcome! Please refer to the [Contribution Guidelines](CONTRIBUTING.md) for more details. 106 | 107 | ## Support 108 | 109 | If you like Snackbar, consider also to check the app ([Lasso - Window Manager for macOS](https://thelasso.app)) where I'm using it'. 110 | -------------------------------------------------------------------------------- /Resources/demo_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iSapozhnik/Snackbar/e205ba54d3d57fed0043fa318851c5ef43a0dcc1/Resources/demo_app.png -------------------------------------------------------------------------------- /Resources/snackbar_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iSapozhnik/Snackbar/e205ba54d3d57fed0043fa318851c5ef43a0dcc1/Resources/snackbar_demo.gif -------------------------------------------------------------------------------- /Sources/Snackbar/Constants.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | public enum Constants { 4 | public static let animationDuration: Double = 0.24 5 | public static let spaceBetweenSnacks: CGFloat = 4.0 6 | public static let cornerRadius: CGFloat = 4.0 7 | public static let padding: CGFloat = 10.0 8 | public static let bottomOffset: CGFloat = 8.0 9 | public static let countDoawnWidth: CGFloat = 16.0 10 | public static let titleFont: NSFont = .systemFont(ofSize: 13) 11 | public static let subtitleFont: NSFont = .systemFont(ofSize: 12) 12 | public static let spaceBetweenTitleAndSubtitle: CGFloat = 2.0 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Snackbar/Snackbar.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | public enum SnackbarStyle { 4 | case alert 5 | case info 6 | case warning 7 | case success 8 | } 9 | 10 | public enum SnackbarType { 11 | case permanent 12 | case temporary(duration: TimeInterval, showProgress: Bool = true) 13 | } 14 | 15 | public enum ActionType { 16 | case primary 17 | case secondary 18 | } 19 | 20 | public struct Text { 21 | let text: String 22 | let font: NSFont? 23 | 24 | public init(text: String, font: NSFont? = nil) { 25 | self.text = text 26 | self.font = font 27 | } 28 | } 29 | 30 | public struct SnackbarAction { 31 | let title: String? 32 | let icon: NSImage? 33 | let type: ActionType 34 | let action: () -> Void 35 | 36 | public init( 37 | title: String? = nil, 38 | icon: NSImage? = nil, 39 | type: ActionType, 40 | action: @escaping () -> Void 41 | ) { 42 | self.title = title 43 | self.icon = icon 44 | self.type = type 45 | self.action = action 46 | } 47 | } 48 | 49 | public enum ActionsLayout { 50 | case horizontal 51 | case vertical 52 | } 53 | 54 | public struct AnimationStyle { 55 | let appear: AppearAnimationStyle 56 | let dissappear: DissappearAnimationStyle 57 | 58 | public init(appear: AppearAnimationStyle, dissappear: DissappearAnimationStyle) { 59 | self.appear = appear 60 | self.dissappear = dissappear 61 | } 62 | } 63 | 64 | public enum AppearAnimationStyle { 65 | case fadeIn 66 | case slideIn 67 | } 68 | 69 | public enum DissappearAnimationStyle { 70 | case fadeOut 71 | case slideOut 72 | } 73 | 74 | private final class WindowDelegate: NSObject, NSWindowDelegate { 75 | var willClose: ((SnackbarWindow) -> Void)? 76 | func windowWillClose(_ notification: Notification) { 77 | guard let window = notification.object as? SnackbarWindow else { return } 78 | willClose?(window) 79 | } 80 | } 81 | 82 | public enum Snackbar { 83 | typealias Animation = (() -> Void) 84 | private static var cache = [SnackbarWindow: Int]() 85 | private static let windowDelegate = WindowDelegate() 86 | private static var dissapearItems = [SnackbarWindow: DispatchWorkItem]() 87 | 88 | public static func clear() { 89 | cache.keys.forEach { $0.close() } 90 | cache.removeAll() 91 | dissapearItems.removeAll() 92 | } 93 | /** 94 | Displays a snackbar with the specified configuration. 95 | 96 | - Parameters: 97 | - theme: The theme of the snackbar. 98 | - type: The type of the snackbar. Default value is `.permanent`. 99 | - title: The title of the snackbar. 100 | - subtitle: The optional subtitle of the snackbar. Default value is `nil`. 101 | - actions: An array of `SnackbarAction` objects representing the actions available in the snackbar. 102 | - actionsLayout: The layout style for displaying the actions. Default value is `.vertical`. 103 | - hasActionsSeparator: A boolean value indicating whether the actions should be separated by a separator. Default value is `false`. 104 | - hasContentSeparator: A boolean value indicating whether the content should be separated by a separator. Default value is `true`. 105 | - icon: The optional icon image to be displayed in the snackbar. Default value is `nil`. 106 | - mainWindow: The main window from which the snackbar will be presented. 107 | - acceptsEqualContent: A boolean value indicating whether the snackbar can display duplicate content. Default value is `true`. 108 | - cornerRadius: The corner radius of the snackbar. Default value is `Constants.cornerRadius`. 109 | - padding: The padding value for the snackbar. Default value is `Constants.padding`. 110 | - bottomOffet: The bottom offset value for the snackbar. Default value is `Constants.bottomOffset`. 111 | - animationStyle: The animation style for the snackbar appearance and disappearance. Default value is `.init(appear: .slideIn, disappear: .fadeOut)`. 112 | 113 | - Note: 114 | The `SnackbarAction` objects in the `actions` array represent the actions that can be performed in the snackbar. Each `SnackbarAction` has a title and a closure that will be executed when the action is triggered. 115 | 116 | - Important: 117 | The `mainWindow` parameter must be provided to ensure the snackbar is presented from the correct window. 118 | */ 119 | public static func show( 120 | theme: SnackbarTheme, 121 | type: SnackbarType = .permanent, 122 | title: Text, 123 | subtitle: Text? = nil, 124 | actions: [SnackbarAction], 125 | actionsLayout: ActionsLayout = .vertical, 126 | hasActionsSeparator: Bool = false, 127 | hasContentSeparator: Bool = true, 128 | icon: NSImage? = nil, 129 | fromWindow mainWindow: NSWindow?, 130 | acceptsEqualContent: Bool = false, 131 | cornerRadius: CGFloat = Constants.cornerRadius, 132 | padding: CGFloat = Constants.padding, 133 | bottomOffet: CGFloat = Constants.bottomOffset, 134 | animationStyle: AnimationStyle = .init(appear: .slideIn, dissappear: .fadeOut) 135 | ) { 136 | guard acceptsEqualContent || !cache.values.contains(title.text.hashValue) else { return } 137 | guard let mainWindow else { return } 138 | 139 | let parentWindowSize = mainWindow.frame.size 140 | 141 | let snackbarView = SnackbarView( 142 | type: type, 143 | title: title, 144 | subtitle: subtitle, 145 | textColor: theme.textColor, 146 | borderColor: theme.borderColor, 147 | backgroundColor: theme.backgroundColor, 148 | cornerRadius: cornerRadius, 149 | padding: padding, 150 | actions: actions, 151 | actionsLayout: actionsLayout, 152 | hasActionsSeparator: hasActionsSeparator, 153 | hasContentSeparator: hasContentSeparator, 154 | icon: icon 155 | ) 156 | 157 | let toastSize = CGSize( 158 | width: round(snackbarView.fittingSize.width), 159 | height: round(snackbarView.fittingSize.height) 160 | ) 161 | 162 | let toastOriginX = (mainWindow.frame.origin.x) + (parentWindowSize.width - toastSize.width) / 2 163 | var toastOriginY = mainWindow.frame.origin.y + bottomOffet 164 | toastOriginY += Array(cache.keys) 165 | .map(\.frame.height) 166 | .reduce(0, +) 167 | toastOriginY += CGFloat(cache.keys.count) * Constants.spaceBetweenSnacks 168 | 169 | let finalRect = NSRect(x: round(toastOriginX), y: round(toastOriginY), width: toastSize.width, height: toastSize.height).insetBy(dx: -1, dy: -1) 170 | 171 | let snackbarWindow = SnackbarWindow(snackbarView: snackbarView, index: cache.keys.count) 172 | snackbarView.onClick = { [weak snackbarWindow] in 173 | guard let snackbarWindow else { return } 174 | dissapearItems[snackbarWindow]?.cancel() 175 | dissappearAnimation(withStyle: animationStyle.dissappear, snackbarWindow: snackbarWindow) 176 | } 177 | snackbarWindow.onClick = { [weak snackbarWindow] in 178 | guard let snackbarWindow else { return } 179 | dissapearItems[snackbarWindow]?.cancel() 180 | dissappearAnimation(withStyle: animationStyle.dissappear, snackbarWindow: snackbarWindow) 181 | } 182 | cache[snackbarWindow] = title.text.hashValue 183 | snackbarWindow.delegate = windowDelegate 184 | windowDelegate.willClose = { window in 185 | cache.removeValue(forKey: window) 186 | window.parent?.removeChildWindow(window) 187 | layoutExistingSnackbars(relativeTo: window, animate: true) 188 | } 189 | mainWindow.addChildWindow(snackbarWindow, ordered: .above) 190 | 191 | appearAnimation(withStyle: animationStyle.appear, snackbarWindow: snackbarWindow, finalRect: finalRect) 192 | guard case let .temporary(duration, _) = type else { return } 193 | let item = DispatchWorkItem(block: { 194 | dissappearAnimation(withStyle: animationStyle.dissappear, snackbarWindow: snackbarWindow) 195 | }) 196 | dissapearItems[snackbarWindow] = item 197 | DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: item) 198 | } 199 | 200 | private static func appearAnimation( 201 | withStyle animationStyle: AppearAnimationStyle, 202 | snackbarWindow: SnackbarWindow, 203 | finalRect: CGRect 204 | ) { 205 | switch animationStyle { 206 | case .fadeIn: 207 | fadeIn(snackbarWindow: snackbarWindow, finalRect: finalRect) 208 | case .slideIn: 209 | slideIn(snackbarWindow: snackbarWindow, finalRect: finalRect) 210 | } 211 | } 212 | 213 | private static func dissappearAnimation( 214 | withStyle animationStyle: DissappearAnimationStyle, 215 | snackbarWindow: SnackbarWindow 216 | ) { 217 | switch animationStyle { 218 | case .fadeOut: 219 | fadeOut(snackbarWindow: snackbarWindow) 220 | case .slideOut: 221 | slideOut(snackbarWindow: snackbarWindow) 222 | } 223 | } 224 | 225 | private static func layoutExistingSnackbars(relativeTo window: SnackbarWindow, animate: Bool) { 226 | cache.keys 227 | .filter { $0.index >= window.index } 228 | .forEach { existingWindow in 229 | existingWindow.index -= 1 230 | var newFrame = existingWindow.frame 231 | newFrame.origin.y -= window.frame.height + Constants.spaceBetweenSnacks 232 | NSAnimationContext.runAnimationGroup { context in 233 | context.duration = Constants.animationDuration 234 | existingWindow.animator().setFrame(newFrame, display: true, animate: animate) 235 | } 236 | } 237 | } 238 | 239 | private static func fadeIn(snackbarWindow: SnackbarWindow, finalRect: CGRect) { 240 | snackbarWindow.alphaValue = 0.0 241 | snackbarWindow.setFrame(finalRect, display: false) 242 | NSAnimationContext.runAnimationGroup { ctx in 243 | ctx.duration = Constants.animationDuration 244 | snackbarWindow.animator().alphaValue = 1.0 245 | } 246 | } 247 | 248 | private static func fadeOut(snackbarWindow: SnackbarWindow) { 249 | NSAnimationContext.runAnimationGroup({ context in 250 | context.duration = Constants.animationDuration 251 | snackbarWindow.animator().alphaValue = 0.0 252 | }, completionHandler: { 253 | snackbarWindow.close() 254 | dissapearItems.removeValue(forKey: snackbarWindow) 255 | }) 256 | } 257 | 258 | private static func slideIn(snackbarWindow: SnackbarWindow, finalRect: CGRect) { 259 | snackbarWindow.alphaValue = 0.0 260 | 261 | var initialFrame = finalRect 262 | initialFrame.origin.y -= snackbarWindow.frame.height 263 | snackbarWindow.setFrame(initialFrame, display: false) 264 | 265 | NSAnimationContext.runAnimationGroup { ctx in 266 | ctx.duration = Constants.animationDuration 267 | snackbarWindow.animator().alphaValue = 1.0 268 | snackbarWindow.animator().setFrame(finalRect, display: true, animate: true) 269 | } 270 | } 271 | 272 | private static func slideOut(snackbarWindow: SnackbarWindow) { 273 | var initialFrame = snackbarWindow.frame 274 | initialFrame.origin.y -= snackbarWindow.frame.height 275 | 276 | NSAnimationContext.runAnimationGroup({ context in 277 | context.duration = Constants.animationDuration 278 | snackbarWindow.animator().alphaValue = 0.0 279 | snackbarWindow.animator().setFrame(initialFrame, display: true, animate: true) 280 | }, completionHandler: { 281 | snackbarWindow.close() 282 | dissapearItems.removeValue(forKey: snackbarWindow) 283 | }) 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /Sources/Snackbar/SnackbarTheme.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | public protocol SnackbarTheme { 4 | var style: SnackbarStyle { get set } 5 | 6 | var textColor: NSColor { get } 7 | var backgroundColor: NSColor { get } 8 | var borderColor: NSColor { get } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Snackbar/SnackbarView.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | final class SnackbarView: NSBox { 4 | var onClick: (() -> Void)? 5 | 6 | private lazy var countDownLabel: NSTextField = { 7 | let countdownDuration: Int 8 | if case let .temporary(duration, _) = type { 9 | countdownDuration = Int(duration) 10 | } else { 11 | countdownDuration = 0 12 | } 13 | 14 | let countDownLabel = NSTextField(labelWithString: String(countdownDuration)) 15 | countDownLabel.alignment = .center 16 | countDownLabel.translatesAutoresizingMaskIntoConstraints = false 17 | countDownLabel.widthAnchor.constraint(equalToConstant: Constants.countDoawnWidth).isActive = true 18 | countDownLabel.isSelectable = false 19 | countDownLabel.textColor = NSColor.secondaryLabelColor 20 | return countDownLabel 21 | }() 22 | 23 | private let type: SnackbarType 24 | private let titleText: Text 25 | private let subtitleText: Text? 26 | private let textColor: NSColor 27 | private let icon: NSImage? 28 | private let padding: CGFloat 29 | private var timer: Timer? 30 | private let actions: [SnackbarAction] 31 | private let actionsLayout: ActionsLayout 32 | private let hasActionsSeparator: Bool 33 | private let hasContentSeparator: Bool 34 | 35 | private var counter: Int = 0 36 | 37 | init( 38 | type: SnackbarType, 39 | title: Text, 40 | subtitle: Text?, 41 | textColor: NSColor, 42 | borderColor: NSColor, 43 | backgroundColor: NSColor, 44 | cornerRadius: CGFloat, 45 | padding: CGFloat, 46 | actions: [SnackbarAction], 47 | actionsLayout: ActionsLayout, 48 | hasActionsSeparator: Bool, 49 | hasContentSeparator: Bool, 50 | icon: NSImage? 51 | ) { 52 | self.type = type 53 | self.titleText = title 54 | self.subtitleText = subtitle 55 | self.textColor = textColor 56 | self.padding = padding 57 | self.actions = actions 58 | self.actionsLayout = actionsLayout 59 | self.hasActionsSeparator = hasActionsSeparator 60 | self.hasContentSeparator = hasContentSeparator 61 | self.icon = icon 62 | super.init(frame: .zero) 63 | fillColor = backgroundColor 64 | self.borderColor = borderColor 65 | boxType = .custom 66 | self.cornerRadius = cornerRadius 67 | contentViewMargins = .zero 68 | configureView() 69 | setupTimer() 70 | } 71 | 72 | @available(*, unavailable) 73 | required init?(coder _: NSCoder) { 74 | fatalError("init(coder:) has not been implemented") 75 | } 76 | 77 | private func setupTimer() { 78 | guard case let .temporary(duration, _) = type else { return } 79 | counter = Int(duration) 80 | timer?.invalidate() 81 | let timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(updateCountDown), userInfo: nil, repeats: true) 82 | RunLoop.main.add(timer, forMode: .common) 83 | self.timer = timer 84 | } 85 | 86 | private func configureView() { 87 | let stackView = NSStackView() 88 | stackView.translatesAutoresizingMaskIntoConstraints = false 89 | stackView.orientation = .horizontal 90 | stackView.alignment = .centerY 91 | 92 | let addSeparator = { 93 | let separator = NSBox() 94 | separator.boxType = .separator 95 | stackView.addArrangedSubview(separator) 96 | } 97 | 98 | if case let .temporary(_, showProgress) = type, showProgress == true { 99 | stackView.addArrangedSubview(countDownLabel) 100 | } 101 | 102 | if let icon { 103 | let imageView = NSImageView(image: icon) 104 | imageView.imageScaling = .scaleNone 105 | imageView.setContentCompressionResistancePriority(.required, for: .vertical) 106 | imageView.setContentCompressionResistancePriority(.required, for: .horizontal) 107 | stackView.addArrangedSubview(imageView) 108 | if hasContentSeparator { addSeparator() } 109 | } 110 | 111 | let textStackView = NSStackView() 112 | textStackView.spacing = Constants.spaceBetweenTitleAndSubtitle 113 | textStackView.alignment = .leading 114 | textStackView.orientation = .vertical 115 | 116 | let titleLabel = NSTextField(labelWithString: titleText.text) 117 | titleLabel.font = titleText.font ?? Constants.titleFont 118 | titleLabel.isSelectable = false 119 | titleLabel.textColor = textColor 120 | textStackView.addArrangedSubview(titleLabel) 121 | 122 | if let subtitleText { 123 | let subtitleLabel = NSTextField(labelWithString: subtitleText.text) 124 | subtitleLabel.font = subtitleText.font ?? Constants.subtitleFont 125 | subtitleLabel.isSelectable = false 126 | subtitleLabel.textColor = .secondaryLabelColor // TODO: Color 127 | textStackView.addArrangedSubview(subtitleLabel) 128 | } 129 | 130 | stackView.addArrangedSubview(textStackView) 131 | 132 | if !actions.isEmpty { 133 | if hasContentSeparator { addSeparator() } 134 | 135 | let actionsStackView = NSStackView() 136 | switch actionsLayout { 137 | case .horizontal: 138 | actionsStackView.orientation = .horizontal 139 | case .vertical: 140 | actionsStackView.orientation = .vertical 141 | } 142 | for (index, action) in actions.enumerated() { 143 | let button = NSButton(title: action.title ?? "", target: self, action: #selector(onButton(_:))) 144 | button.tag = index 145 | button.alignment = .center 146 | button.contentTintColor = action.type == .primary ? textColor : .secondaryLabelColor 147 | button.font = NSFont.systemFont(ofSize: 14, weight: action.type == .primary ? .semibold : .regular) 148 | 149 | if let icon = action.icon { 150 | button.imagePosition = .imageTrailing 151 | button.image = icon 152 | button.imageScaling = .scaleProportionallyDown 153 | } 154 | button.isBordered = false 155 | actionsStackView.addArrangedSubview(button) 156 | 157 | if index != actions.count - 1, hasActionsSeparator { 158 | let separator = NSBox() 159 | separator.boxType = .separator 160 | actionsStackView.addArrangedSubview(separator) 161 | } 162 | } 163 | stackView.addArrangedSubview(actionsStackView) 164 | } 165 | 166 | addSubview(stackView) 167 | 168 | NSLayoutConstraint.activate([ 169 | stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding), 170 | stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -padding), 171 | stackView.topAnchor.constraint(equalTo: topAnchor, constant: padding), 172 | stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -padding), 173 | ]) 174 | } 175 | 176 | @objc private func updateCountDown() { 177 | counter -= 1 178 | countDownLabel.stringValue = String(counter) 179 | } 180 | 181 | @objc 182 | private func onButton(_ sender: NSButton) { 183 | onClick?() 184 | guard 185 | actions.indices.contains(sender.tag) 186 | else { return } 187 | let action = actions[sender.tag] 188 | action.action() 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /Sources/Snackbar/SnackbarWindow.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | final class SnackbarWindow: NSWindow { 4 | var index: Int 5 | let snackbarView: SnackbarView 6 | var onClick: (() -> Void)? 7 | 8 | init( 9 | snackbarView: SnackbarView, 10 | index: Int 11 | ) { 12 | self.snackbarView = snackbarView 13 | self.index = index 14 | 15 | super.init(contentRect: .zero, styleMask: [.borderless], backing: .buffered, defer: false) 16 | configureWindow() 17 | } 18 | 19 | @available(*, unavailable) 20 | required init?(coder _: NSCoder) { 21 | fatalError("init(coder:) has not been implemented") 22 | } 23 | 24 | override func contentRect(forFrameRect frameRect: NSRect) -> NSRect { 25 | frameRect 26 | } 27 | 28 | override func mouseDown(with _: NSEvent) { 29 | // close() 30 | onClick?() 31 | } 32 | 33 | private func configureWindow() { 34 | backgroundColor = .clear 35 | isOpaque = false 36 | level = .floating 37 | ignoresMouseEvents = false 38 | hasShadow = true 39 | isReleasedWhenClosed = false 40 | 41 | let content = NSView(frame: frame) 42 | content.wantsLayer = true 43 | content.layer?.backgroundColor = .clear 44 | 45 | content.addSubview(snackbarView) 46 | snackbarView.translatesAutoresizingMaskIntoConstraints = false 47 | NSLayoutConstraint.activate([ 48 | snackbarView.leadingAnchor.constraint(equalTo: content.leadingAnchor), 49 | snackbarView.trailingAnchor.constraint(equalTo: content.trailingAnchor), 50 | snackbarView.topAnchor.constraint(equalTo: content.topAnchor), 51 | snackbarView.bottomAnchor.constraint(equalTo: content.bottomAnchor), 52 | ]) 53 | contentView = content 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Tests/SnackbarTests/SnackbarTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Snackbar 3 | 4 | final class SnackbarTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(Snackbar().text, "Hello, World!") 10 | } 11 | } 12 | --------------------------------------------------------------------------------