diff --git a/VDSSample.xcodeproj/project.pbxproj b/VDSSample.xcodeproj/project.pbxproj index efac15b..a08ddff 100644 --- a/VDSSample.xcodeproj/project.pbxproj +++ b/VDSSample.xcodeproj/project.pbxproj @@ -40,8 +40,16 @@ EA3C3BB528996775000CA526 /* StoryboardInitable.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA3C3BB128996775000CA526 /* StoryboardInitable.swift */; }; EA3C3BB628996775000CA526 /* MenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA3C3BB228996775000CA526 /* MenuViewController.swift */; }; EA3C3BB728996775000CA526 /* ToggleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA3C3BB328996775000CA526 /* ToggleViewController.swift */; }; - EA89200A28B52934006B9984 /* CheckboxGroupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA89200928B52934006B9984 /* CheckboxGroupViewController.swift */; }; EA89201928B56DF5006B9984 /* RadioBoxGroupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA89201828B56DF5006B9984 /* RadioBoxGroupViewController.swift */; }; + EA89204628B66CE2006B9984 /* ScrollViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA89203F28B66CE2006B9984 /* ScrollViewController.swift */; }; + EA89204728B66CE2006B9984 /* KeyboardFrameChangeListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA89204028B66CE2006B9984 /* KeyboardFrameChangeListener.swift */; }; + EA89204828B66CE2006B9984 /* ScrollViewKeyboardAvoiding.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA89204128B66CE2006B9984 /* ScrollViewKeyboardAvoiding.swift */; }; + EA89204928B66CE2006B9984 /* KeyboardFrameChangeListening.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA89204228B66CE2006B9984 /* KeyboardFrameChangeListening.swift */; }; + EA89204A28B66CE2006B9984 /* KeyboardFrameChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA89204328B66CE2006B9984 /* KeyboardFrameChange.swift */; }; + EA89204B28B66CE2006B9984 /* ScrollViewKeyboardAvoider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA89204428B66CE2006B9984 /* ScrollViewKeyboardAvoider.swift */; }; + EA89204C28B66CE2006B9984 /* ScrollWrapperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA89204528B66CE2006B9984 /* ScrollWrapperView.swift */; }; + EA89204E28B67332006B9984 /* CheckBoxGroupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA89204D28B67332006B9984 /* CheckBoxGroupViewController.swift */; }; + EA89205128B68307006B9984 /* TextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA89205028B68307006B9984 /* TextField.swift */; }; EAB1D2C528A6B11D00DAE764 /* TestViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB1D2C428A6B11D00DAE764 /* TestViewController.swift */; }; EAB1D2C928AAAA1D00DAE764 /* ModelViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB1D2C828AAAA1D00DAE764 /* ModelViewController.swift */; }; EAB1D2CB28AAB9E200DAE764 /* TemplateViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB1D2CA28AAB9E200DAE764 /* TemplateViewController.swift */; }; @@ -95,8 +103,16 @@ EA3C3BBA289968A0000CA526 /* VDSTypographyTokens.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = VDSTypographyTokens.xcframework; path = ../SharedFrameworks/VDSTypographyTokens.xcframework; sourceTree = ""; }; EA3C3BBB289968A0000CA526 /* VDSFormControlsTokens.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = VDSFormControlsTokens.xcframework; path = ../SharedFrameworks/VDSFormControlsTokens.xcframework; sourceTree = ""; }; EA3C3BC3289968B1000CA526 /* VDS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = VDS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - EA89200928B52934006B9984 /* CheckboxGroupViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxGroupViewController.swift; sourceTree = ""; }; EA89201828B56DF5006B9984 /* RadioBoxGroupViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioBoxGroupViewController.swift; sourceTree = ""; }; + EA89203F28B66CE2006B9984 /* ScrollViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollViewController.swift; sourceTree = ""; }; + EA89204028B66CE2006B9984 /* KeyboardFrameChangeListener.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardFrameChangeListener.swift; sourceTree = ""; }; + EA89204128B66CE2006B9984 /* ScrollViewKeyboardAvoiding.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollViewKeyboardAvoiding.swift; sourceTree = ""; }; + EA89204228B66CE2006B9984 /* KeyboardFrameChangeListening.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardFrameChangeListening.swift; sourceTree = ""; }; + EA89204328B66CE2006B9984 /* KeyboardFrameChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardFrameChange.swift; sourceTree = ""; }; + EA89204428B66CE2006B9984 /* ScrollViewKeyboardAvoider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollViewKeyboardAvoider.swift; sourceTree = ""; }; + EA89204528B66CE2006B9984 /* ScrollWrapperView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollWrapperView.swift; sourceTree = ""; }; + EA89204D28B67332006B9984 /* CheckBoxGroupViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckBoxGroupViewController.swift; sourceTree = ""; }; + EA89205028B68307006B9984 /* TextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextField.swift; sourceTree = ""; }; EAB1D2C428A6B11D00DAE764 /* TestViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestViewController.swift; sourceTree = ""; }; EAB1D2C828AAAA1D00DAE764 /* ModelViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelViewController.swift; sourceTree = ""; }; EAB1D2CA28AAB9E200DAE764 /* TemplateViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateViewController.swift; sourceTree = ""; }; @@ -156,6 +172,7 @@ children = ( EAF7F0C3289DA24F00B287F5 /* Supporting Files */, EAF7F07E28996A0700B287F5 /* Protocols */, + EA89204F28B682F4006B9984 /* Classes */, EAF7F07F28996A1900B287F5 /* ViewControllers */, EA3C3B9C289966EF000CA526 /* AppDelegate.swift */, EA3C3B9E289966EF000CA526 /* SceneDelegate.swift */, @@ -184,6 +201,28 @@ name = Frameworks; sourceTree = ""; }; + EA89203E28B66CE2006B9984 /* ScrollViewController */ = { + isa = PBXGroup; + children = ( + EA89203F28B66CE2006B9984 /* ScrollViewController.swift */, + EA89204028B66CE2006B9984 /* KeyboardFrameChangeListener.swift */, + EA89204128B66CE2006B9984 /* ScrollViewKeyboardAvoiding.swift */, + EA89204228B66CE2006B9984 /* KeyboardFrameChangeListening.swift */, + EA89204328B66CE2006B9984 /* KeyboardFrameChange.swift */, + EA89204428B66CE2006B9984 /* ScrollViewKeyboardAvoider.swift */, + EA89204528B66CE2006B9984 /* ScrollWrapperView.swift */, + ); + path = ScrollViewController; + sourceTree = ""; + }; + EA89204F28B682F4006B9984 /* Classes */ = { + isa = PBXGroup; + children = ( + EA89205028B68307006B9984 /* TextField.swift */, + ); + path = Classes; + sourceTree = ""; + }; EAF7F0792899698800B287F5 /* Resources */ = { isa = PBXGroup; children = ( @@ -207,8 +246,9 @@ EAF7F07F28996A1900B287F5 /* ViewControllers */ = { isa = PBXGroup; children = ( + EA89203E28B66CE2006B9984 /* ScrollViewController */, EA3C3BB228996775000CA526 /* MenuViewController.swift */, - EA89200928B52934006B9984 /* CheckboxGroupViewController.swift */, + EA89204D28B67332006B9984 /* CheckBoxGroupViewController.swift */, EAF7F09B2899B92400B287F5 /* CheckboxViewController.swift */, EAB1D2D328AC409F00DAE764 /* LabelViewController.swift */, EAB1D2C828AAAA1D00DAE764 /* ModelViewController.swift */, @@ -362,19 +402,27 @@ buildActionMask = 2147483647; files = ( EA3C3BB728996775000CA526 /* ToggleViewController.swift in Sources */, + EA89204C28B66CE2006B9984 /* ScrollWrapperView.swift in Sources */, + EA89205128B68307006B9984 /* TextField.swift in Sources */, EA3C3BB528996775000CA526 /* StoryboardInitable.swift in Sources */, EA89201928B56DF5006B9984 /* RadioBoxGroupViewController.swift in Sources */, EA3C3BB628996775000CA526 /* MenuViewController.swift in Sources */, EA3C3B9D289966EF000CA526 /* AppDelegate.swift in Sources */, EAF7F11A28A14A0E00B287F5 /* RadioButtonViewController.swift in Sources */, EAB1D2CB28AAB9E200DAE764 /* TemplateViewController.swift in Sources */, + EA89204628B66CE2006B9984 /* ScrollViewController.swift in Sources */, EA3C3B9F289966EF000CA526 /* SceneDelegate.swift in Sources */, + EA89204A28B66CE2006B9984 /* KeyboardFrameChange.swift in Sources */, EA3C3BB428996775000CA526 /* PickerBase.swift in Sources */, EAB1D2C528A6B11D00DAE764 /* TestViewController.swift in Sources */, EAB1D2C928AAAA1D00DAE764 /* ModelViewController.swift in Sources */, - EA89200A28B52934006B9984 /* CheckboxGroupViewController.swift in Sources */, + EA89204728B66CE2006B9984 /* KeyboardFrameChangeListener.swift in Sources */, + EA89204828B66CE2006B9984 /* ScrollViewKeyboardAvoiding.swift in Sources */, EAF7F09C2899B92400B287F5 /* CheckboxViewController.swift in Sources */, + EA89204E28B67332006B9984 /* CheckBoxGroupViewController.swift in Sources */, + EA89204928B66CE2006B9984 /* KeyboardFrameChangeListening.swift in Sources */, EAB1D2D428AC409F00DAE764 /* LabelViewController.swift in Sources */, + EA89204B28B66CE2006B9984 /* ScrollViewKeyboardAvoider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/VDSSample/Classes/TextField.swift b/VDSSample/Classes/TextField.swift new file mode 100644 index 0000000..b1349f0 --- /dev/null +++ b/VDSSample/Classes/TextField.swift @@ -0,0 +1,32 @@ +// +// TextField.swift +// VDSSample +// +// Created by Matt Bruce on 8/24/22. +// + +import Foundation +import UIKit + +class TextField: UITextField { + var textPadding = UIEdgeInsets( + top: 10, + left: 10, + bottom: 10, + right: 10 + ) + + override func textRect(forBounds bounds: CGRect) -> CGRect { + layer.borderColor = UIColor.black.cgColor + layer.borderWidth = 1 + let rect = super.textRect(forBounds: bounds) + return rect.inset(by: textPadding) + } + + override func editingRect(forBounds bounds: CGRect) -> CGRect { + layer.borderColor = UIColor.black.cgColor + layer.borderWidth = 1 + let rect = super.editingRect(forBounds: bounds) + return rect.inset(by: textPadding) + } +} diff --git a/VDSSample/Protocols/PickerBase.swift b/VDSSample/Protocols/PickerBase.swift index 8ac8e2d..9403ea7 100644 --- a/VDSSample/Protocols/PickerBase.swift +++ b/VDSSample/Protocols/PickerBase.swift @@ -44,6 +44,28 @@ class PickerBase: NSObject, PickerViewable, UIPicker } } +class PickerSelectorView: UIStackView { + var label = UILabel() + var button = UIButton(type: .system).with { instance in + instance.configuration = .filled() + instance.setTitle("Select", for: .normal) + } + + init(title: String){ + super.init(frame: .zero) + self.axis = .horizontal + self.distribution = .fillEqually + self.alignment = .fill + label.text = title + addArrangedSubview(label) + addArrangedSubview(button) + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + class SurfacePicker: PickerBase { init(){ super.init(items: [.light, .dark]) diff --git a/VDSSample/ViewControllers/CheckboxGroupViewController.swift b/VDSSample/ViewControllers/CheckBoxGroupViewController.swift similarity index 52% rename from VDSSample/ViewControllers/CheckboxGroupViewController.swift rename to VDSSample/ViewControllers/CheckBoxGroupViewController.swift index 288b670..eb29afe 100644 --- a/VDSSample/ViewControllers/CheckboxGroupViewController.swift +++ b/VDSSample/ViewControllers/CheckBoxGroupViewController.swift @@ -1,8 +1,8 @@ // -// CheckboxViewController.swift +// CheckBoxGroup2ViewController.swift // VDSSample // -// Created by Matt Bruce on 8/1/22. +// Created by Matt Bruce on 8/24/22. // import Foundation @@ -11,33 +11,69 @@ import VDS import VDSColorTokens import Combine -class CheckboxGroupViewController: ModelViewController, StoryboardInitable { - +class CheckboxGroupViewController: ModelScrollViewController { + enum PickerType { case surface } - static var storyboardId: String = "checkboxGroup" - static var storyboardName: String = "Components" - @IBOutlet weak var checkboxContainerView: UIView! - @IBOutlet weak var picker: UIPickerView! - @IBOutlet weak var surfaceLabel: UILabel! - - @IBOutlet weak var disabledSwitch: UISwitch! - @IBOutlet weak var labelTextField: UITextField! - @IBOutlet weak var childTextField: UITextField! - @IBOutlet weak var showErrorSwitch: UISwitch! + var picker = UIPickerView() + var surfacePickerSelectorView = PickerSelectorView(title: "light") + var disabledSwitch = UISwitch() + var labelTextField = TextField() + var childTextField = TextField() + var showErrorSwitch = UISwitch() + var checkboxGroup = CheckboxGroup() override func viewDidLoad() { super.viewDidLoad() - checkboxContainerView.addSubview(checkboxGroup) - checkboxGroup.leadingAnchor.constraint(equalTo: checkboxContainerView.leadingAnchor, constant: 10).isActive = true - checkboxGroup.bottomAnchor.constraint(equalTo: checkboxContainerView.bottomAnchor, constant: -20).isActive = true - checkboxGroup.topAnchor.constraint(equalTo: checkboxContainerView.topAnchor, constant: 20).isActive = true - checkboxGroup.trailingAnchor.constraint(equalTo: checkboxContainerView.trailingAnchor, constant: 10).isActive = true - view.addGestureRecognizer(UITapGestureRecognizer(target: self.view, action: #selector(UIView.endEditing(_:)))) + addContentTopView(view: checkboxGroup) + + addFormRow(label: "Disabled", view: disabledSwitch) + addFormRow(label: "Surface", view: surfacePickerSelectorView) + addFormRow(label: "Label Text", view: labelTextField) + addFormRow(label: "Childe Text", view: childTextField) + addFormRow(label: "Error", view: showErrorSwitch) + + checkboxGroup + .handlerPublisher() + .sink { [weak self] viewModel in + self?.model = viewModel + }.store(in: &subscribers) + + showErrorSwitch + .publisher(for: .valueChanged) + .sink { [weak self] sender in + self?.checkboxGroup.hasError = sender.isOn + }.store(in: &subscribers) + + disabledSwitch + .publisher(for: .valueChanged) + .sink { [weak self] sender in + self?.checkboxGroup.disabled = sender.isOn + }.store(in: &subscribers) + + labelTextField + .textPublisher + .sink { [weak self] text in + self?.checkbox?.labelText = text + }.store(in: &subscribers) + + childTextField + .textPublisher + .sink { [weak self] text in + self?.checkbox?.childText = text + }.store(in: &subscribers) + + + surfacePickerSelectorView.button + .publisher(for: .touchUpInside) + .sink { [weak self] _ in + self?.pickerType = .surface + }.store(in: &subscribers) + setupPicker() setupModel() } @@ -55,15 +91,9 @@ class CheckboxGroupViewController: ModelViewController: UIViewController, ModelH open func updateView(viewModel: ModelType) {} } + + +public class ModelScrollViewController: UIViewController, ModelHandlerable, Initable { + deinit { + print("\(Self.self) deinit") + } + + //-------------------------------------------------- + // MARK: - Combine Properties + //-------------------------------------------------- + @Published public var model: ModelType = ModelType() + public var modelPublisher: Published.Publisher { $model } + public var subscribers = Set() + + //-------------------------------------------------- + // MARK: - Properties + //-------------------------------------------------- + private var initialSetupPerformed = false + + @Proxy(\.model.surface) + open var surface: Surface + + @Proxy(\.model.disabled) + open var disabled: Bool + + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- + required public init() { + super.init(nibName: nil, bundle: nil) + initialSetup() + set(with: model) + } + + public required init(with model: ModelType) { + super.init(nibName: nil, bundle: nil) + initialSetup() + set(with: model) + } + + public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nil, bundle: nil) + initialSetup() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + initialSetup() + } + + //-------------------------------------------------- + // MARK: - Setup + //-------------------------------------------------- + + public func initialSetup() { + if !initialSetupPerformed { + initialSetupPerformed = true + setupUpdateView() + setup() + } + } + + public var contentStackView: UIStackView = { + return UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.alignment = .fill + $0.distribution = .fillProportionally + $0.axis = .vertical + } + }() + + public var formStackView: UIStackView = { + return UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.alignment = .fill + $0.distribution = .fillProportionally + $0.axis = .vertical + $0.spacing = 10 + } + }() + + public var contentTopView: UIView = { + return UIView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + } + }() + + public var contentBottomView: UIView = { + return UIView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + } + }() + + open override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + + embed(scrollViewController) + scrollViewController.scrollView.alwaysBounceVertical = true + scrollViewController.contentView = contentStackView + contentStackView.addArrangedSubview(contentTopView) + contentStackView.addArrangedSubview(contentBottomView) + contentBottomView.addSubview(formStackView) + formStackView.translatesAutoresizingMaskIntoConstraints = false + formStackView.topAnchor.constraint(equalTo: contentBottomView.topAnchor, constant: 16).isActive = true + formStackView.leadingAnchor.constraint(equalTo: contentBottomView.leadingAnchor, constant: 16).isActive = true + formStackView.trailingAnchor.constraint(equalTo: contentBottomView.trailingAnchor, constant: -16).isActive = true + formStackView.bottomAnchor.constraint(equalTo: contentBottomView.bottomAnchor, constant: -16).isActive = true + + } + + private let scrollViewController = ScrollViewController() + + private func embed(_ viewController: UIViewController) { + addChild(viewController) + view.addSubview(viewController.view) + viewController.view.translatesAutoresizingMaskIntoConstraints = false + viewController.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true + viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true + viewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true + viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true + viewController.didMove(toParent: self) + } + + open func addContentTopView(view: UIView) { + contentTopView.addSubview(view) + view.leadingAnchor.constraint(equalTo: contentTopView.leadingAnchor, constant: 16).isActive = true + view.trailingAnchor.constraint(equalTo: contentTopView.trailingAnchor, constant: -16).isActive = true + view.topAnchor.constraint(equalTo: contentTopView.topAnchor, constant: 20).isActive = true + view.bottomAnchor.constraint(equalTo: contentTopView.bottomAnchor, constant: -20).isActive = true + } + + open func addFormRow(label: String, view: UIView) { + let formRow = UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.alignment = .fill + $0.distribution = .fillEqually + $0.axis = .horizontal + $0.spacing = 5 + } + + let label = UILabel().with { + $0.text = label + } + + formRow.addArrangedSubview(label) + formRow.addArrangedSubview(view) + + formStackView.addArrangedSubview(formRow) + } + + open func setup() {} + + open func shouldUpdateView(viewModel: ModelType) -> Bool { true } + + open func updateView(viewModel: ModelType) {} + +} diff --git a/VDSSample/ViewControllers/ScrollViewController/KeyboardFrameChange.swift b/VDSSample/ViewControllers/ScrollViewController/KeyboardFrameChange.swift new file mode 100644 index 0000000..2fba9f5 --- /dev/null +++ b/VDSSample/ViewControllers/ScrollViewController/KeyboardFrameChange.swift @@ -0,0 +1,21 @@ +import CoreGraphics +import Foundation + +/// Represents keyboard frame change. +public struct KeyboardFrameChange { + /// Create new frame-change object. + /// + /// - Parameters: + /// - frame: new keyboard frame + /// - animationDuration: change frame animation duration + public init(frame: CGRect, animationDuration: TimeInterval) { + self.frame = frame + self.animationDuration = animationDuration + } + + /// New keyboard frame. + public let frame: CGRect + + /// Frame change animation duration. + public let animationDuration: TimeInterval +} diff --git a/VDSSample/ViewControllers/ScrollViewController/KeyboardFrameChangeListener.swift b/VDSSample/ViewControllers/ScrollViewController/KeyboardFrameChangeListener.swift new file mode 100644 index 0000000..8d57145 --- /dev/null +++ b/VDSSample/ViewControllers/ScrollViewController/KeyboardFrameChangeListener.swift @@ -0,0 +1,47 @@ +import UIKit + +/// `KeyboardFrameChangeListining` implementation. +public final class KeyboardFrameChangeListener: KeyboardFrameChangeListening { + /// Create new listener. + /// + /// - Parameter notificationCenter: Source of keyboard frame change notifications. + public init(notificationCenter: NotificationCenter) { + self.notificationCenter = notificationCenter + observe() + } + + // MARK: - KeyboardFrameChangeListening + + public var keyboardFrameWillChange: ((KeyboardFrameChange) -> Void)? + + // MARK: - Internals + + private let notificationCenter: NotificationCenter + private var token: NSObjectProtocol? + + private func observe() { + token = notificationCenter.addObserver( + forName: UIResponder.keyboardWillChangeFrameNotification, + object: nil, + queue: nil, + using: { [weak self] in self?.handle($0) } + ) + } + + private func handle(_ notification: Notification) { + guard let endFrame = notification.keyboardFrameEnd, + let animationDuration = notification.keyboardAnimationDuration else { return } + let change = KeyboardFrameChange(frame: endFrame, animationDuration: animationDuration) + keyboardFrameWillChange?(change) + } +} + +private extension Notification { + var keyboardFrameEnd: CGRect? { + return userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + } + + var keyboardAnimationDuration: Double? { + return userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double + } +} diff --git a/VDSSample/ViewControllers/ScrollViewController/KeyboardFrameChangeListening.swift b/VDSSample/ViewControllers/ScrollViewController/KeyboardFrameChangeListening.swift new file mode 100644 index 0000000..4d6fcf2 --- /dev/null +++ b/VDSSample/ViewControllers/ScrollViewController/KeyboardFrameChangeListening.swift @@ -0,0 +1,5 @@ +/// Listens for keyboard frame changes. +public protocol KeyboardFrameChangeListening: AnyObject { + /// Called when keyboard frame is about to change. + var keyboardFrameWillChange: ((KeyboardFrameChange) -> Void)? { get set } +} diff --git a/VDSSample/ViewControllers/ScrollViewController/ScrollViewController.swift b/VDSSample/ViewControllers/ScrollViewController/ScrollViewController.swift new file mode 100644 index 0000000..9ed194a --- /dev/null +++ b/VDSSample/ViewControllers/ScrollViewController/ScrollViewController.swift @@ -0,0 +1,93 @@ +import UIKit + +/// Scroll View Controller. +public class ScrollViewController: UIViewController, UIScrollViewDelegate { + /// Animates using provided duration and closure. + public typealias Animator = (TimeInterval, @escaping () -> Void) -> Void + + /// Create new instance. + /// + /// - Parameters: + /// - keyboardFrameChangeListener: Used to observe keybaord frame changes. + /// - scrollViewKeyboardAvoider: Used to apply keyboard-avoiding insets to `UIScrollView`. + /// - wrapperViewFactory: Used to create `ScrollWrapperView`. + /// - animator: Closure used to animate layout changes. + public init(keyboardFrameChangeListener: KeyboardFrameChangeListening = KeyboardFrameChangeListener(notificationCenter: NotificationCenter.default), + scrollViewKeyboardAvoider: ScrollViewKeyboardAvoiding = ScrollViewKeyboardAvoider(animator: { UIView.animate(withDuration: $0, animations: $1) }), + wrapperViewFactory: @escaping () -> ScrollWrapperView = { ScrollWrapperView() }, + animator: @escaping Animator = { UIView.animate(withDuration: $0, animations: $1) }) { + self.keyboardFrameChangeListener = keyboardFrameChangeListener + self.scrollViewKeyboardAvoider = scrollViewKeyboardAvoider + self.createWrapperView = wrapperViewFactory + self.animate = animator + super.init(nibName: nil, bundle: nil) + } + + /// Does nothing, this class is designed to be used programmatically. + required public init?(coder aDecoder: NSCoder) { nil } + + // MARK: - View + + override public func loadView() { + view = createWrapperView() + } + + override public func viewDidLoad() { + super.viewDidLoad() + wrapperView.scrollView.delegate = self + keyboardFrameChangeListener.keyboardFrameWillChange = { [unowned self] change in + self.scrollViewKeyboardAvoider.handleKeyboardFrameChange( + change.frame, + animationDuration: change.animationDuration, + for: self.wrapperView.scrollView + ) + self.updateVisibleContentInset(scrollView: self.wrapperView.scrollView) + self.animate(change.animationDuration) { + self.wrapperView.layoutIfNeeded() + } + } + } + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + updateVisibleContentInset(scrollView: wrapperView.scrollView) + } + + /// Contained `UIScrollView`. + public var scrollView: UIScrollView { + return wrapperView.scrollView + } + + /// Scrollable content view. + public var contentView: UIView? { + get { return wrapperView.contentView } + set { wrapperView.contentView = newValue } + } + + /// Main view of this view controller (non-scrollable). + public var wrapperView: ScrollWrapperView! { + return view as? ScrollWrapperView + } + + // MARK: - UIScrollViewDelegate + + @available(iOS 11.0, *) + public func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) { + updateVisibleContentInset(scrollView: scrollView) + } + + // MARK: - Internals + + private let keyboardFrameChangeListener: KeyboardFrameChangeListening + private let scrollViewKeyboardAvoider: ScrollViewKeyboardAvoiding + private let createWrapperView: () -> ScrollWrapperView + private let animate: Animator + + private func updateVisibleContentInset(scrollView: UIScrollView) { + if #available(iOS 11.0, *) { + wrapperView.visibleContentInsets = scrollView.adjustedContentInset + } else { + wrapperView.visibleContentInsets = scrollView.contentInset + } + } +} diff --git a/VDSSample/ViewControllers/ScrollViewController/ScrollViewKeyboardAvoider.swift b/VDSSample/ViewControllers/ScrollViewController/ScrollViewKeyboardAvoider.swift new file mode 100644 index 0000000..22dad75 --- /dev/null +++ b/VDSSample/ViewControllers/ScrollViewController/ScrollViewKeyboardAvoider.swift @@ -0,0 +1,42 @@ +import UIKit + +/// `ScrollViewKeyboardAvoiding` implementation. +public final class ScrollViewKeyboardAvoider: ScrollViewKeyboardAvoiding { + /// Animates using provided duration and closure + public typealias Animator = (TimeInterval, @escaping () -> Void) -> Void + + /// Create new avoider + /// + /// - Parameter animator: used to perform animations + public init(animator: @escaping Animator) { + self.animate = animator + } + + // MARK: - ScrollViewKeyboardAvoiding + + public func handleKeyboardFrameChange( + _ frame: CGRect, + animationDuration: TimeInterval, + for scrollView: UIScrollView + ) { + guard let superview = scrollView.superview else { return } + let keyboardFrame = superview.convert(frame, from: nil) + var insets = scrollView.contentInset + let bottomCoverage = scrollView.frame.maxY - keyboardFrame.minY + let safeAreaInsets: UIEdgeInsets + if #available(iOS 11.0, *) { + safeAreaInsets = scrollView.safeAreaInsets + } else { + safeAreaInsets = .zero + } + insets.bottom = max(0, bottomCoverage - safeAreaInsets.bottom) + animate(animationDuration) { + scrollView.contentInset = insets + scrollView.scrollIndicatorInsets = insets + } + } + + // MARK: - Internals + + private let animate: Animator +} diff --git a/VDSSample/ViewControllers/ScrollViewController/ScrollViewKeyboardAvoiding.swift b/VDSSample/ViewControllers/ScrollViewController/ScrollViewKeyboardAvoiding.swift new file mode 100644 index 0000000..36b52eb --- /dev/null +++ b/VDSSample/ViewControllers/ScrollViewController/ScrollViewKeyboardAvoiding.swift @@ -0,0 +1,12 @@ +import UIKit + +/// Adjusts insets of `UIScrollView` so the keyboard does not cover content. +public protocol ScrollViewKeyboardAvoiding { + /// Handle keyboard frame change. + /// + /// - Parameters: + /// - frame: New frame of the keyboard. + /// - animationDuration: Frame change animation duration. + /// - scrollView: Target `UIScrollView`. + func handleKeyboardFrameChange(_ frame: CGRect, animationDuration: TimeInterval, for scrollView: UIScrollView) +} diff --git a/VDSSample/ViewControllers/ScrollViewController/ScrollWrapperView.swift b/VDSSample/ViewControllers/ScrollViewController/ScrollWrapperView.swift new file mode 100644 index 0000000..775796d --- /dev/null +++ b/VDSSample/ViewControllers/ScrollViewController/ScrollWrapperView.swift @@ -0,0 +1,191 @@ +import UIKit + +/// `UIScrollView` wrapper that allows configuring how the scrollable content is laid out. +public class ScrollWrapperView: UIView { + /// Create `UIScrollView` wrapper view. + public init() { + scrollView = UIScrollView(frame: .zero) + super.init(frame: .zero) + scrollView.keyboardDismissMode = .interactive + addSubview(scrollView) + scrollView.addSubview(contentWrapperView) + setupLayout() + } + + /// Does nothing, this class is designed to be used programmatically. + required public init?(coder aDecoder: NSCoder) { nil } + + // MARK: - Subviews + + /// Wrapped `UIScrollView`. + public let scrollView: UIScrollView + + /// Scrollable content view. + public var contentView: UIView? { + didSet { + oldValue?.removeFromSuperview() + contentViewTopEqualSuper = nil + contentViewTopGreaterThanSuper = nil + contentViewLeft = nil + contentViewRight = nil + contentViewBottom = nil + if let newValue = contentView { + contentWrapperView.addSubview(newValue) + setupLayout(contentView: newValue) + } + } + } + + let contentWrapperView = UIView() + + // MARK: - Layout configuration + + /// If `true`, `contentView` will be stretched to fill visible area. + /// + /// Default is `true`. + public var contentViewStretching = true { + didSet { contentWrapperHeight.isActive = contentViewStretching } + } + + /// If `true` the content view will be aligned to the bottom of scrollable area. + /// + /// Default is `false`. + /// + /// If the `contentViewStretching` is set to `false` this property makes no changes to the alignemnt. + public var alignContentToBottom = false { + didSet { + contentViewTopGreaterThanSuper?.isActive = alignContentToBottom == true + contentViewTopEqualSuper?.isActive = alignContentToBottom == false + } + } + + /// Scrollable content insets. + /// + /// Default is `.zero` which means no insets. + public var contentInsets: UIEdgeInsets = .zero { + didSet { + contentViewTopEqualSuper?.constant = contentInsets.top + contentViewTopGreaterThanSuper?.constant = contentInsets.top + contentViewLeft?.constant = contentInsets.left + contentViewRight?.constant = -contentInsets.right + contentViewBottom?.constant = -contentInsets.bottom + } + } + + // MARK: - Touch handling configuration + + /// If `true` touches outside the `contentView` will be handled and allow scrolling. + /// + /// Default is `true`. + public var handlesTouchesOutsideContent = true + + public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if handlesTouchesOutsideContent { + return super.hitTest(point, with: event) + } + if let contentView = contentView, contentView.bounds.contains(convert(point, to: contentView)) { + return super.hitTest(point, with: event) + } + return nil + } + + // MARK: - Internals + + var visibleContentInsets: UIEdgeInsets { + get { + UIEdgeInsets( + top: visibleContentLayoutGuideTop.constant, + left: visibleContentLayoutGuideLeft.constant, + bottom: -visibleContentLayoutGuideBottom.constant, + right: -visibleContentLayoutGuideRight.constant + ) + } + set { + visibleContentLayoutGuideTop.constant = newValue.top + visibleContentLayoutGuideLeft.constant = newValue.left + visibleContentLayoutGuideRight.constant = -newValue.right + visibleContentLayoutGuideBottom.constant = -newValue.bottom + } + } + + private let visibleContentLayoutGuide = UILayoutGuide() + private var visibleContentLayoutGuideTop: NSLayoutConstraint! + private var visibleContentLayoutGuideLeft: NSLayoutConstraint! + private var visibleContentLayoutGuideRight: NSLayoutConstraint! + private var visibleContentLayoutGuideBottom: NSLayoutConstraint! + private var contentWrapperHeight: NSLayoutConstraint! + private var contentViewTopEqualSuper: NSLayoutConstraint? + private var contentViewTopGreaterThanSuper: NSLayoutConstraint? + private var contentViewLeft: NSLayoutConstraint? + private var contentViewRight: NSLayoutConstraint? + private var contentViewBottom: NSLayoutConstraint? + + private func setupLayout() { + setupVisibleContentLayoutGuide() + setupScrollViewLayout() + setupContentWrapperViewLayout() + } + + private func setupScrollViewLayout() { + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.topAnchor.constraint(equalTo: topAnchor).isActive = true + scrollView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true + scrollView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true + } + + private func setupContentWrapperViewLayout() { + contentWrapperView.translatesAutoresizingMaskIntoConstraints = false + contentWrapperView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true + contentWrapperView.leftAnchor.constraint(equalTo: scrollView.leftAnchor).isActive = true + contentWrapperView.rightAnchor.constraint(equalTo: scrollView.rightAnchor).isActive = true + contentWrapperView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true + contentWrapperView.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true + contentWrapperHeight = contentWrapperView.heightAnchor.constraint( + greaterThanOrEqualTo: visibleContentLayoutGuide.heightAnchor + ) + contentWrapperHeight.isActive = contentViewStretching + } + + private func setupVisibleContentLayoutGuide() { + addLayoutGuide(visibleContentLayoutGuide) + + visibleContentLayoutGuideTop = visibleContentLayoutGuide.topAnchor.constraint(equalTo: topAnchor) + visibleContentLayoutGuideTop.isActive = true + + visibleContentLayoutGuideLeft = visibleContentLayoutGuide.leftAnchor.constraint(equalTo: leftAnchor) + visibleContentLayoutGuideLeft.isActive = true + + visibleContentLayoutGuideRight = visibleContentLayoutGuide.rightAnchor.constraint(equalTo: rightAnchor) + visibleContentLayoutGuideRight.priority = .defaultHigh + visibleContentLayoutGuideRight.isActive = true + + visibleContentLayoutGuideBottom = visibleContentLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor) + visibleContentLayoutGuideBottom.priority = .defaultHigh + visibleContentLayoutGuideBottom.isActive = true + } + + private func setupLayout(contentView view: UIView) { + view.translatesAutoresizingMaskIntoConstraints = false + + contentViewTopEqualSuper = view.topAnchor.constraint(equalTo: contentWrapperView.topAnchor) + contentViewTopEqualSuper?.constant = contentInsets.top + contentViewTopEqualSuper?.isActive = alignContentToBottom == false + + contentViewTopGreaterThanSuper = view.topAnchor.constraint(greaterThanOrEqualTo: contentWrapperView.topAnchor) + contentViewTopGreaterThanSuper?.constant = contentInsets.top + contentViewTopGreaterThanSuper?.isActive = alignContentToBottom == true + + contentViewLeft = view.leftAnchor.constraint(equalTo: contentWrapperView.leftAnchor) + contentViewLeft?.constant = contentInsets.left + contentViewLeft?.isActive = true + + contentViewRight = view.rightAnchor.constraint(equalTo: contentWrapperView.rightAnchor) + contentViewRight?.constant = -contentInsets.right + contentViewRight?.isActive = true + + contentViewBottom = view.bottomAnchor.constraint(equalTo: contentWrapperView.bottomAnchor) + contentViewBottom?.constant = -contentInsets.bottom + contentViewBottom?.isActive = true + } +} diff --git a/VDSSample/ViewControllers/TestViewController.swift b/VDSSample/ViewControllers/TestViewController.swift index f0034b3..2ef9924 100644 --- a/VDSSample/ViewControllers/TestViewController.swift +++ b/VDSSample/ViewControllers/TestViewController.swift @@ -128,26 +128,3 @@ class UserNameView: UIControl { }.store(in: &subscriptions) } } - -class TextField: UITextField { - var textPadding = UIEdgeInsets( - top: 10, - left: 20, - bottom: 10, - right: 20 - ) - - override func textRect(forBounds bounds: CGRect) -> CGRect { - layer.borderColor = UIColor.black.cgColor - layer.borderWidth = 1 - let rect = super.textRect(forBounds: bounds) - return rect.inset(by: textPadding) - } - - override func editingRect(forBounds bounds: CGRect) -> CGRect { - layer.borderColor = UIColor.black.cgColor - layer.borderWidth = 1 - let rect = super.editingRect(forBounds: bounds) - return rect.inset(by: textPadding) - } -}