diff --git a/Material.xcodeproj/project.pbxproj b/Material.xcodeproj/project.pbxproj index 612f9a34a..de93b6798 100644 --- a/Material.xcodeproj/project.pbxproj +++ b/Material.xcodeproj/project.pbxproj @@ -175,6 +175,9 @@ 9D054A6620D175AC00D0528D /* Material+UILabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D054A6420D175AC00D0528D /* Material+UILabel.swift */; }; 9D39A81B20FE8ED100BA8FA1 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D39A81A20FE8ED100BA8FA1 /* ViewController.swift */; }; 9D9089B92118914500605DC9 /* Editor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D9089B82118914500605DC9 /* Editor.swift */; }; + 9DE25DE02170D7AF000C04DF /* Dialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DE25DDF2170D7AF000C04DF /* Dialog.swift */; }; + 9DE25DE22170D7C0000C04DF /* DialogController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DE25DE12170D7C0000C04DF /* DialogController.swift */; }; + 9DE25DE42170D7FF000C04DF /* DialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DE25DE32170D7FF000C04DF /* DialogView.swift */; }; 9DE84D721FF0252600586C8B /* RadioButtonGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DE84D6F1FF0252500586C8B /* RadioButtonGroup.swift */; }; 9DE84D731FF0252600586C8B /* BaseButtonGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DE84D701FF0252500586C8B /* BaseButtonGroup.swift */; }; 9DE84D741FF0252600586C8B /* CheckButtonGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DE84D711FF0252500586C8B /* CheckButtonGroup.swift */; }; @@ -298,6 +301,9 @@ 9D054A6420D175AC00D0528D /* Material+UILabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Material+UILabel.swift"; sourceTree = ""; }; 9D39A81A20FE8ED100BA8FA1 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 9D9089B82118914500605DC9 /* Editor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Editor.swift; sourceTree = ""; }; + 9DE25DDF2170D7AF000C04DF /* Dialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dialog.swift; sourceTree = ""; }; + 9DE25DE12170D7C0000C04DF /* DialogController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialogController.swift; sourceTree = ""; }; + 9DE25DE32170D7FF000C04DF /* DialogView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DialogView.swift; sourceTree = ""; }; 9DE84D6F1FF0252500586C8B /* RadioButtonGroup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RadioButtonGroup.swift; sourceTree = ""; }; 9DE84D701FF0252500586C8B /* BaseButtonGroup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseButtonGroup.swift; sourceTree = ""; }; 9DE84D711FF0252500586C8B /* CheckButtonGroup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CheckButtonGroup.swift; sourceTree = ""; }; @@ -550,6 +556,7 @@ 96BCB8001CB40F0300C806FE /* Color */, 96328B9A1E05C135009A4C90 /* Data */, 96BCB80B1CB410CC00C806FE /* Device */, + 9DE25DDE2170D779000C04DF /* Dialogs */, 96230AB61D6A51FD00AF47DC /* Divider */, 96BCB80A1CB410A100C806FE /* Extension */, 963FBF021D6696D0008F8512 /* FABMenu */, @@ -771,6 +778,16 @@ name = Theme; sourceTree = ""; }; + 9DE25DDE2170D779000C04DF /* Dialogs */ = { + isa = PBXGroup; + children = ( + 9DE25DDF2170D7AF000C04DF /* Dialog.swift */, + 9DE25DE12170D7C0000C04DF /* DialogController.swift */, + 9DE25DE32170D7FF000C04DF /* DialogView.swift */, + ); + name = Dialogs; + sourceTree = ""; + }; 9DE84D6E1FF0250E00586C8B /* ButtonGroup */ = { isa = PBXGroup; children = ( @@ -1015,9 +1032,12 @@ 9DE84D721FF0252600586C8B /* RadioButtonGroup.swift in Sources */, 9D00EBB4216675FB00DBCD69 /* Theme.swift in Sources */, 965E80FE1DD4D59500D61E4B /* ToolbarController.swift in Sources */, + 9DE25DE22170D7C0000C04DF /* DialogController.swift in Sources */, + 9DE25DE42170D7FF000C04DF /* DialogView.swift in Sources */, 96328B971E05C0BB009A4C90 /* TableView.swift in Sources */, 965E80F81DD4D59500D61E4B /* ImageCard.swift in Sources */, 96328B991E05C0CE009A4C90 /* TableViewController.swift in Sources */, + 9DE25DE02170D7AF000C04DF /* Dialog.swift in Sources */, 965E80F91DD4D59500D61E4B /* PresenterCard.swift in Sources */, 96E09DC81F2287E50000B121 /* TabsController.swift in Sources */, 961154CC1F32A7B100A78D74 /* ChipBar.swift in Sources */, diff --git a/Sources/iOS/Button.swift b/Sources/iOS/Button.swift index fe0462666..1a572540f 100644 --- a/Sources/iOS/Button.swift +++ b/Sources/iOS/Button.swift @@ -191,9 +191,9 @@ open class Button: UIButton, Pulseable, PulseableLayer, Themeable { /** A convenience initializer that acceps an image and tint - Parameter image: A UIImage. - - Parameter tintColor: A UI + - Parameter tintColor: A UIColor. */ - public init(image: UIImage?, tintColor: UIColor = Color.blue.base) { + public init(image: UIImage?, tintColor: UIColor? = nil) { super.init(frame: .zero) prepare() prepare(with: image, tintColor: tintColor) @@ -202,9 +202,9 @@ open class Button: UIButton, Pulseable, PulseableLayer, Themeable { /** A convenience initializer that acceps a title and title - Parameter title: A String. - - Parameter titleColor: A UI + - Parameter titleColor: A UIColor. */ - public init(title: String?, titleColor: UIColor = Color.blue.base) { + public init(title: String?, titleColor: UIColor? = nil) { super.init(frame: .zero) prepare() prepare(with: title, titleColor: titleColor) @@ -309,9 +309,9 @@ extension Button { - Parameter image: A UIImage. - Parameter tintColor: A UI */ - fileprivate func prepare(with image: UIImage?, tintColor: UIColor) { + fileprivate func prepare(with image: UIImage?, tintColor: UIColor?) { self.image = image - self.tintColor = tintColor + self.tintColor = tintColor ?? self.tintColor } /** @@ -319,9 +319,9 @@ extension Button { - Parameter title: A String. - Parameter titleColor: A UI */ - fileprivate func prepare(with title: String?, titleColor: UIColor) { + fileprivate func prepare(with title: String?, titleColor: UIColor?) { self.title = title - self.titleColor = titleColor + self.titleColor = titleColor ?? self.titleColor } } diff --git a/Sources/iOS/Dialog.swift b/Sources/iOS/Dialog.swift new file mode 100644 index 000000000..846445cf8 --- /dev/null +++ b/Sources/iOS/Dialog.swift @@ -0,0 +1,298 @@ +/* + * Copyright (C) 2018, Daniel Dahan and CosmicMind, Inc. . + * All rights reserved. + * + * Original Inspiration & Author + * Copyright (C) 2018 Orkhan Alikhanov + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * * Neither the name of CosmicMind nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import UIKit +import Motion + +@objc +public protocol DialogDelegate { + /** + A delegation method that is executed when the Dialog is cancelled through tapping background. + - Parameter _ dialog: A Dialog. + */ + @objc + optional func dialogDidCancel(_ dialog: Dialog) + + /** + A delegation method that is executed when the Dialog will appear. + - Parameter _ dialog: A Dialog. + */ + @objc + optional func dialogWillAppear(_ dialog: Dialog) + + /** + A delegation method that is executed when the Dialog did disappear. + - Parameter _ dialog: A Dialog. + */ + @objc + optional func dialogDidDisappear(_ dialog: Dialog) + + /** + A delegation method that is executed to determine if the Dialog should be dismissed. + - Parameter _ dialog: A Dialog. + - Parameter shouldDismiss button: The tapped button. nil if dialog is being + cancelled through tapping background. + - Returns: A Boolean. + */ + @objc + optional func dialog(_ dialog: Dialog, shouldDismiss button: Button?) -> Bool + + /** + A delegation method that is executed when the positive button of Dialog is tapped. + - Parameter _ dialog: A Dialog. + - Parameter didTapPositive button: A Button. + */ + @objc + optional func dialog(_ dialog: Dialog, didTapPositive button: Button) + + /** + A delegation method that is executed when the negative button of Dialog is tapped. + - Parameter _ dialog: A Dialog. + - Parameter didTapNegative button: A Button. + */ + @objc + optional func dialog(_ dialog: Dialog, didTapNegative button: Button) + + /** + A delegation method that is executed when the neutral button of Dialog is tapped. + - Parameter _ dialog: A Dialog. + - Parameter didTapNeutral button: A Button. + */ + @objc + optional func dialog(_ dialog: Dialog, didTapNeutral button: Button) +} + +/// A builder for DialogController. +open class Dialog: NSObject { + /// A reference to dialog controller. + public let controller = DialogController() + + /// A weak reference to DialogDelegate. + open weak var delegate: DialogDelegate? + + /// An empty initializer. + public override init() { + super.init() + + /// Set callbacks for delegate. + shouldDismiss(handler: nil) + .positive(nil, handler: nil) + .negative(nil, handler: nil) + .neutral(nil, handler: nil) + .isCancelable(controller.isCancelable, handler: nil) + .willAppear(handler: nil) + .didDisappear(handler: nil) + } + + /** + Sets title of the dialog. + - Parameter _ text: A string. + - Returns: Dialog itself to allow chaining. + */ + @discardableResult + open func title(_ text: String?) -> Dialog { + dialogView.titleLabel.text = text + return self + } + + /** + Sets details of the dialog. + - Parameter _ text: A string. + - Returns: Dialog itself to allow chaining. + */ + @discardableResult + open func details(_ text: String?) -> Dialog { + dialogView.detailsLabel.text = text + return self + } + + /** + Sets title and handler for positive button of dialog. + - Parameter _ title: A string. + - Parameter handler: A closure handling tap. + - Returns: Dialog itself to allow chaining. + */ + @discardableResult + open func positive(_ title: String?, handler: (() -> Void)?) -> Dialog { + dialogView.positiveButton.title = title + controller.didTapPositiveButtonHandler = { [unowned self] in + self.delegate?.dialog?(self, didTapPositive: self.controller.dialogView.positiveButton) + handler?() + } + return self + } + + /** + Sets title and handler for negative button of dialog. + - Parameter _ title: A string. + - Parameter handler: A closure handling tap. + - Returns: Dialog itself to allow chaining. + */ + @discardableResult + open func negative(_ title: String?, handler: (() -> Void)?) -> Dialog { + dialogView.negativeButton.title = title + controller.didTapNegativeButtonHandler = { [unowned self] in + self.delegate?.dialog?(self, didTapNegative: self.controller.dialogView.negativeButton) + handler?() + } + return self + } + + /** + Sets title and handler for neutral button of dialog. + - Parameter _ title: A string. + - Parameter handler: A closure handling tap. + - Returns: Dialog itself to allow chaining. + */ + @discardableResult + open func neutral(_ title: String?, handler: (() -> Void)?) -> Dialog { + dialogView.neutralButton.title = title + controller.didTapNeutralButtonHandler = { [unowned self] in + self.delegate?.dialog?(self, didTapNeutral: self.controller.dialogView.neutralButton) + handler?() + } + return self + } + + /** + Sets cancelability of dialog and handler for when it's cancelled. + - Parameter _ value: A Bool. + - Parameter handler: A closure handling cancellation. + - Returns: Dialog itself to allow chaining. + */ + @discardableResult + open func isCancelable(_ value: Bool, handler: (() -> Void)? = nil) -> Dialog { + controller.isCancelable = value + controller.didCancelHandler = { [unowned self] in + self.delegate?.dialogDidCancel?(self) + handler?() + } + return self + } + + /** + Sets should-dismiss handler of dialog which takes dialogView and tapped + button and returns a boolean indicating if dialog should be dismissed. + - Parameter handler: A closure handling if dialog can be dismissed. + - Returns: Dialog itself to allow chaining. + */ + @discardableResult + open func shouldDismiss(handler: ((DialogView, Button?) -> Bool)?) -> Dialog { + controller.shouldDismissHandler = { [unowned self] dialogView, button in + let d = self.delegate?.dialog?(self, shouldDismiss: button) ?? true + let h = handler?(dialogView, button) ?? true + return d && h + } + return self + } + + /** + Sets handler for when view controller will appear. + - Parameter handler: A closure handling the event. + - Returns: Dialog itself to allow chaining. + */ + @discardableResult + open func willAppear(handler: (() -> Void)?) -> Dialog { + controller.willAppear = { [unowned self] in + self.delegate?.dialogWillAppear?(self) + handler?() + } + return self + } + + /** + Sets handler for when view controller did disappear. + - Parameter handler: A closure handling the event. + - Returns: Dialog itself to allow chaining. + */ + @discardableResult + open func didDisappear(handler: (() -> Void)?) -> Dialog { + controller.didDisappear = { [unowned self] in + self.delegate?.dialogDidDisappear?(self) + handler?() + self.controller.dialog = nil + } + return self + } + + /** + Sets dialog delegate. + - Parameter delegate: A DialogDelegate. + - Returns: Dialog itself to allow chaining. + */ + @discardableResult + open func delegate(_ delegate: DialogDelegate) -> Dialog { + self.delegate = delegate + return self + } + + /** + Presents dialog modally from given viewController. + - Parameter _ viewController: A UIViewController. + - Returns: Dialog itself to allow chaining. + */ + @discardableResult + open func show(_ viewController: UIViewController) -> Dialog { + controller.dialog = self + viewController.present(controller, animated: true, completion: nil) + return self + } +} + +private extension Dialog { + /// Returns dialogView of controller. + var dialogView: DialogView { + return controller.dialogView + } +} + +/// A memory reference to companion Dialog instance. +private var DialogKey: UInt8 = 0 + +private extension DialogController { + /** + A Dialog instance attached to the dialog controller. + This is used to keep Dialog alive throughout the lifespan + of the controller. + */ + var dialog: Dialog? { + get { + return AssociatedObject.get(base: self, key: &DialogKey) { + return nil + } + } + set(value) { + AssociatedObject.set(base: self, key: &DialogKey, value: value) + } + } +} diff --git a/Sources/iOS/DialogController.swift b/Sources/iOS/DialogController.swift new file mode 100644 index 000000000..cd4fb6ed0 --- /dev/null +++ b/Sources/iOS/DialogController.swift @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2018, Daniel Dahan and CosmicMind, Inc. . + * All rights reserved. + * + * Original Inspiration & Author + * Copyright (C) 2018 Orkhan Alikhanov + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * * Neither the name of CosmicMind nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import UIKit + +/// A UIViewController managing DialogView. +open class DialogController: UIViewController { + /// A reference to dialogView. + public let dialogView = T() + + /// A boolean indicating cancelability of dialog when user taps on background. + open var isCancelable = false + + /// A reference to did-cancel handler. + open var didCancelHandler: (() -> Void)? + + /** + A reference to should-dismiss handler which takes dialogView + and tapped button and returns Boolean indicating if dialog should be dismissed. + */ + open var shouldDismissHandler: ((T, Button?) -> Bool)? + + /// A reference to handler for when positiveButton is tapped. + open var didTapPositiveButtonHandler: (() -> Void)? + + /// A reference to handler for when negativeButton is tapped. + open var didTapNegativeButtonHandler: (() -> Void)? + + /// A reference to handler for when neutralButton is tapped. + open var didTapNeutralButtonHandler: (() -> Void)? + + /// A reference to handler for when controller will appear. + open var willAppear: (() -> Void)? + + /// A reference to handler for when controller did disappear. + open var didDisappear: (() -> Void)? + + public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + prepare() + } + + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + prepare() + } + + /// Prepares controller for presentation. + open func prepare() { + isMotionEnabled = true + motionTransitionType = .fade + modalPresentationStyle = .overFullScreen + } + + open override func viewDidLoad() { + super.viewDidLoad() + prepareView() + prepareDialogView() + } + + open override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + willAppear?() + } + + open override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + didDisappear?() + } + + open override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + dialogView.maxSize = CGSize(width: Screen.width * 0.8, height: Screen.height * 0.9) + } + + /** + Dismisses dialog. + - Parameter isAnimated: A boolean. + */ + open func dismiss(isAnimated: Bool = true) { + dismiss(isTriggeredByUserInteraction: false, isAnimated: isAnimated) + } + + /// Handler for when background scrim is tapped. + @objc + private func didTapBackgroundView() { + guard isCancelable else { + return + } + + dismiss(isTriggeredByUserInteraction: true, isAnimated: true) + } + + /// Handler for when one of 3 dialog buttons is tapped. + @objc + private func didTapButton(_ sender: Button) { + switch sender { + case dialogView.positiveButton: + didTapPositiveButtonHandler?() + + case dialogView.negativeButton: + didTapNegativeButtonHandler?() + + case dialogView.neutralButton: + didTapNeutralButtonHandler?() + + default: + break + } + + dismiss(isTriggeredByUserInteraction: true, isAnimated: true, using: sender) + } +} + +private extension DialogController { + /// Prepares view. + func prepareView() { + let v = UIControl() + v.backgroundColor = Color.black.withAlphaComponent(0.33) + v.addTarget(self, action: #selector(didTapBackgroundView), for: .touchUpInside) + view = v + } + + /// Prepares dialogView. + func prepareDialogView() { + view.layout(dialogView).center() + dialogView.buttonArea.subviews.forEach { + ($0 as? Button)?.addTarget(self, action: #selector(didTapButton), for: .touchUpInside) + } + } +} + +private extension DialogController { + /** + Dismisses dialog. + - Parameter isTriggeredByUserInteraction: A boolean indicating whether the action is + triggered by a user interaction + - Parameter isAnimated: A boolean indicating if the dismissal should be animated. + - Parameter using button: A button triggering the dismissal. + */ + func dismiss(isTriggeredByUserInteraction: Bool, isAnimated: Bool, using button: Button? = nil) { + if isTriggeredByUserInteraction { + guard shouldDismissHandler?(dialogView, button) ?? true else { + return + } + } + + presentingViewController?.dismiss(animated: isAnimated, completion: nil) + + guard isTriggeredByUserInteraction, nil == button else { + return + } + + didCancelHandler?() + } +} diff --git a/Sources/iOS/DialogView.swift b/Sources/iOS/DialogView.swift new file mode 100644 index 000000000..87922e8a2 --- /dev/null +++ b/Sources/iOS/DialogView.swift @@ -0,0 +1,430 @@ +/* + * Copyright (C) 2018, Daniel Dahan and CosmicMind, Inc. . + * All rights reserved. + * + * Original Inspiration & Author + * Copyright (C) 2018 Orkhan Alikhanov + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * * Neither the name of CosmicMind nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import UIKit + +private struct Constants { + struct titleArea { + static let insets = UIEdgeInsets(top: 24, left: 24, bottom: 20, right: 24) + } + + struct contentArea { + static let insets = UIEdgeInsets(top: 0, left: 24, bottom: 24, right: 24) + static let insetsNoTitle = UIEdgeInsets(top: 20, left: 24, bottom: 24, right: 24) + } + + struct buttonArea { + static let insets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) + static let insetsStacked = UIEdgeInsets(top: 6, left: 8, bottom: 14, right: 8) + } + + struct button { + static let insets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + static let minWidth: CGFloat = 64 + static let height: CGFloat = 36 + static let interimStacked: CGFloat = 12 + static let interim: CGFloat = 8 + } +} + +private class DialogScrollView: UIScrollView { + /// A weak reference to DialogView. + weak var dialogView: DialogView? + + override func layoutSubviews() { + super.layoutSubviews() + dialogView?.layoutDividers() + } +} + +open class DialogView: View, Themeable { + /// A container view for title area. + public let titleArea = UIView() + + /// A container view for button area. + public let buttonArea = UIView() + + /// A container view for content area. + public let contentArea = UIView() + + /// A scroll view containing contentArea. + public let scrollView: UIScrollView = DialogScrollView() + + /// A UILabel. + public let titleLabel = UILabel() + + /// A UILabel. + public let detailsLabel = UILabel() + + /// A Button. + public let neutralButton = FlatButton() + + /// A Button. + public let positiveButton = FlatButton() + + /// A Button. + public let negativeButton = FlatButton() + + /// Maximum size of the dialog. + open var maxSize = CGSize(width: 200, height: 300) { + didSet { + invalidateIntrinsicContentSize() + } + } + + open override func prepare() { + super.prepare() + + depthPreset = .depth5 + cornerRadiusPreset = .cornerRadius2 + prepareTitleArea() + prepareTitleLabel() + prepareScrollView() + prepareContentArea() + prepareDetailsLabel() + prepareButtonArea() + prepareButtons() + applyCurrentTheme() + } + + open override var intrinsicContentSize: CGSize { + return sizeThatFits(maxSize) + } + + open override func sizeThatFits(_ size: CGSize) -> CGSize { + var w: CGFloat = 0 + func setMaxWidth(_ width: CGFloat) { + w = max(w, width) + w = min(w, size.width) + } + + setMaxWidth(titleAreaSizeThatFits(width: size.width).width) + setMaxWidth(buttonAreaSizeThatFits(width: size.width).width) + setMaxWidth(contentAreaSizeThatFits(width: size.width).width) + + var h: CGFloat = 0 + h += titleAreaSizeThatFits(width: w).height + h += buttonAreaSizeThatFits(width: w).height + h += contentAreaSizeThatFits(width: w).height + h = min(h, size.height) + + return CGSize(width: w, height: h) + } + + open override func layoutSubviews() { + super.layoutSubviews() + + layoutTitleArea() + layoutButtonArea() + layoutContentArea() + layoutScrollView() + layoutDividers() + + /// Position button area after having correct sizes. + buttonArea.frame.origin.y = scrollView.frame.maxY + } + + /** + Calculates the size for title area that best fits the specified width. + - Parameter width: A CGFloat. + - Returns: Calculated CGSize. + */ + open func titleAreaSizeThatFits(width: CGFloat) -> CGSize { + guard !titleLabel.isEmpty else { + return .zero + } + + let insets = Constants.titleArea.insets + var size = titleLabel.sizeThatFits(CGSize(width: width - insets.left - insets.right, height: .greatestFiniteMagnitude)) + size.width += insets.left + insets.right + size.height += insets.top + insets.bottom + return size + } + + /** + Calculates the size for button area that best fits the specified width. + - Parameter width: A CGFloat. + - Returns: Calculated CGSize. + */ + open func buttonAreaSizeThatFits(width: CGFloat) -> CGSize { + guard !nonHiddenButtons.isEmpty else { + return .zero + } + + let isStacked = requiredButtonAreaWidth > width + let buttonsHeight = Constants.button.height * CGFloat(isStacked ? nonHiddenButtons.count : 1) + let buttonsInterim = isStacked ? CGFloat(nonHiddenButtons.count - 1) * Constants.button.interimStacked : 0 + let insets = isStacked ? Constants.buttonArea.insetsStacked : Constants.buttonArea.insets + let h = buttonsInterim + buttonsHeight + insets.bottom + insets.top + let w = min(width, isStacked ? requiredButtonAreaWidthForStacked : requiredButtonAreaWidth) + return CGSize(width: w, height: h) + } + + /** + Calculates the size for content area that best fits the specified width. + - Parameter width: A CGFloat. + - Returns: Calculated CGSize. + */ + open func contentAreaSizeThatFits(width: CGFloat) -> CGSize { + guard !detailsLabel.isEmpty else { + return .zero + } + + let insets = titleLabel.isEmpty ? Constants.contentArea.insetsNoTitle : Constants.contentArea.insets + var size = detailsLabel.sizeThatFits(CGSize(width: width - insets.left - insets.right, height: .greatestFiniteMagnitude)) + size.width += insets.left + insets.right + size.height += insets.top + insets.bottom + return size + } + + /** + Applies the given theme. + - Parameter theme: A Theme. + */ + open func apply(theme: Theme) { + backgroundColor = theme.surface + titleLabel.textColor = theme.onSurface.withAlphaComponent(0.87) + detailsLabel.textColor = theme.onSurface.withAlphaComponent(0.60) + + titleArea.dividerColor = theme.onSurface.withAlphaComponent(0.12) + buttonArea.dividerColor = theme.onSurface.withAlphaComponent(0.12) + } +} + +private extension DialogView { + /// Prepares titleArea. + func prepareTitleArea() { + addSubview(titleArea) + titleArea.dividerColor = Color.darkText.dividers + titleArea.dividerThickness = 1 + titleArea.dividerAlignment = .bottom + } + + /// Prepares titleTitle. + func prepareTitleLabel() { + titleArea.addSubview(titleLabel) + titleLabel.font = RobotoFont.bold(with: 20) + titleLabel.textColor = Color.darkText.primary + titleLabel.numberOfLines = 0 + } + + /// Prepares buttonArea. + func prepareButtonArea() { + addSubview(buttonArea) + buttonArea.dividerColor = Color.darkText.dividers + buttonArea.dividerThickness = 1 + buttonArea.dividerAlignment = .top + } + + /// Prepares buttons. + func prepareButtons() { + [positiveButton, negativeButton, neutralButton].forEach { + buttonArea.addSubview($0) + $0.titleLabel?.font = RobotoFont.medium(with: 14) + $0.contentEdgeInsets = Constants.button.insets + $0.cornerRadiusPreset = .cornerRadius1 + } + } + + /// Prepares scrollView. + func prepareScrollView() { + (scrollView as! DialogScrollView).dialogView = self + addSubview(scrollView) + } + + /// Prepares contentArea. + func prepareContentArea() { + scrollView.addSubview(contentArea) + } + + /// Prepares detailsLabel. + func prepareDetailsLabel() { + contentArea.addSubview(detailsLabel) + detailsLabel.numberOfLines = 0 + detailsLabel.textColor = Color.darkText.secondary + } +} + +private extension DialogView { + /// Layout the titleArea. + func layoutTitleArea() { + let size = CGSize(width: frame.width, height: titleAreaSizeThatFits(width: frame.width).height) + titleArea.frame.size = size + + guard !titleLabel.isEmpty else { + return + } + + let rect = CGRect(origin: .zero, size: size) + titleLabel.frame = rect.inset(by: Constants.titleArea.insets) + } + + /// Layout the buttonArea. + func layoutButtonArea() { + let width = frame.width + buttonArea.frame.size.width = width + buttonArea.frame.size.height = buttonAreaSizeThatFits(width: width).height + + let buttons = nonHiddenButtons + guard !buttons.isEmpty else { + return + } + + let isStacked = requiredButtonAreaWidth > width + if isStacked { + let insets = Constants.buttonArea.insetsStacked + buttons.forEach { + let w = min($0.optimalWidth, width - insets.left - insets.right) + $0.frame.size = CGSize(width: w, height: Constants.button.height) + $0.frame.origin.x = width - insets.right - w + } + + positiveButton.frame.origin.y = insets.top + let belowPositive = positiveButton.isHidden ? insets.top : positiveButton.frame.maxY + Constants.button.interimStacked + negativeButton.frame.origin.y = belowPositive + neutralButton.frame.origin.y = negativeButton.isHidden ? belowPositive : negativeButton.frame.maxY + Constants.button.interimStacked + } else { + let insets = Constants.buttonArea.insets + buttons.forEach { + $0.frame.size = CGSize(width: $0.optimalWidth, height: Constants.button.height) + $0.frame.origin.y = insets.top + } + + neutralButton.frame.origin.x = insets.left + positiveButton.frame.origin.x = width - insets.right - positiveButton.frame.width + + let maxX = positiveButton.isHidden ? width - insets.right : positiveButton.frame.minX - Constants.button.interim + negativeButton.frame.origin.x = maxX - negativeButton.frame.width + } + } + + /// Layout the contentArea. + func layoutContentArea() { + let size = CGSize(width: frame.width, height: contentAreaSizeThatFits(width: frame.width).height) + contentArea.frame.size = size + guard !detailsLabel.isEmpty else { + return + } + + let rect = CGRect(origin: .zero, size: size) + let insets = titleArea.frame.height == 0 ? Constants.contentArea.insetsNoTitle : Constants.contentArea.insets + detailsLabel.frame = rect.inset(by: insets) + } + + /// Layout the scrollView. + func layoutScrollView() { + let h = titleArea.frame.height + buttonArea.frame.height + let allowed = min(frame.height - h, contentArea.frame.height) + + scrollView.frame.size = CGSize(width: frame.width, height: max(allowed, 0)) + scrollView.frame.origin.y = titleArea.frame.maxY + + scrollView.contentSize = contentArea.frame.size + } + + /** + Layout the dividers. + This method is also called (by scrollView) when scrolling happens + */ + func layoutDividers() { + let isScrollable = contentArea.frame.height > scrollView.frame.height + + titleArea.isDividerHidden = titleArea.frame.height == 0 || !isScrollable || scrollView.isAtTop + buttonArea.isDividerHidden = buttonArea.frame.height == 0 || !isScrollable || scrollView.isAtBottom + + titleArea.layoutDivider() + buttonArea.layoutDivider() + } +} + +private extension DialogView { + /// Required width to fit content of buttonArea. + var requiredButtonAreaWidth: CGFloat { + let buttons = nonHiddenButtons + guard !buttons.isEmpty else { + return 0 + } + + let buttonsWidth: CGFloat = buttons.reduce(0) { $0 + $1.optimalWidth } + let additional: CGFloat = neutralButton.isHidden ? 0 : 8 // additional spacing for neutral button + let insets = Constants.buttonArea.insets + return buttonsWidth + insets.left + insets.right + CGFloat((buttons.count - 1)) * Constants.button.interim + additional + } + + /// Required width to fit statcked content of buttonArea. + var requiredButtonAreaWidthForStacked: CGFloat { + let insets = Constants.buttonArea.insetsStacked + return insets.left + insets.right + nonHiddenButtons.reduce(0) { + max($0, $1.optimalWidth) + } + } + + /// Non-hidden buttons within buttonArea. + var nonHiddenButtons: [Button] { + positiveButton.isHidden = positiveButton.title(for: .normal)?.isEmpty ?? true + negativeButton.isHidden = negativeButton.title(for: .normal)?.isEmpty ?? true + neutralButton.isHidden = neutralButton.title(for: .normal)?.isEmpty ?? true + return [positiveButton, negativeButton, neutralButton].filter { !$0.isHidden } + } +} + +private extension UIScrollView { + /// Checks if scroll view is at the top. + var isAtTop: Bool { + return contentOffset.y <= 0 + } + + /// Checks if scroll view is at the bottom. + var isAtBottom: Bool { + /// -1 is used to get rid of precision errors + /// make divider appear even when scroll is at the bottom. + return contentOffset.y >= (contentSize.height - frame.height - 1) + } +} + +private extension Button { + /// Optimal width for dialog button. + var optimalWidth: CGFloat { + let size = CGSize(width: .greatestFiniteMagnitude, height: Constants.button.height) + return max(Constants.button.minWidth, sizeThatFits(size).width) + } +} + +private extension UILabel { + /// Checks if label is empty. + var isEmpty: Bool { + let empty = text?.isEmpty ?? true + isHidden = empty + return empty + } +}