From 6f08ea1627d3075b3e0988ee7fe6e95896292293 Mon Sep 17 00:00:00 2001 From: Orkhan Alikhanov Date: Sun, 24 Dec 2017 22:10:09 +0400 Subject: [PATCH 1/9] Added RadioButton and CheckButton --- Material.xcodeproj/project.pbxproj | 12 ++ Sources/iOS/BaseIconLayerButton.swift | 203 ++++++++++++++++++++++++++ Sources/iOS/CheckButton.swift | 179 +++++++++++++++++++++++ Sources/iOS/RadioButton.swift | 114 +++++++++++++++ 4 files changed, 508 insertions(+) create mode 100644 Sources/iOS/BaseIconLayerButton.swift create mode 100644 Sources/iOS/CheckButton.swift create mode 100644 Sources/iOS/RadioButton.swift diff --git a/Material.xcodeproj/project.pbxproj b/Material.xcodeproj/project.pbxproj index bd9bf254e..1324df752 100644 --- a/Material.xcodeproj/project.pbxproj +++ b/Material.xcodeproj/project.pbxproj @@ -174,6 +174,9 @@ 96E3C39A1D3A1CC20086A024 /* ErrorTextField.swift in Headers */ = {isa = PBXBuildFile; fileRef = 961F18E71CD93E3E008927C5 /* ErrorTextField.swift */; settings = {ATTRIBUTES = (Public, ); }; }; 96E3C39C1D3A1CC20086A024 /* Offset.swift in Headers */ = {isa = PBXBuildFile; fileRef = 968C99461D377849000074FF /* Offset.swift */; settings = {ATTRIBUTES = (Public, ); }; }; 96F1A5531F24F17A001D8CAF /* TabsController.swift in Headers */ = {isa = PBXBuildFile; fileRef = 96E09DC71F2287E50000B121 /* TabsController.swift */; settings = {ATTRIBUTES = (Public, ); }; }; + 9DF352421FED20C900B2A11B /* BaseIconLayerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DF352411FED20C900B2A11B /* BaseIconLayerButton.swift */; }; + 9DF352441FED20ED00B2A11B /* RadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DF352431FED20ED00B2A11B /* RadioButton.swift */; }; + 9DF352461FED210000B2A11B /* CheckButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DF352451FED210000B2A11B /* CheckButton.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -285,6 +288,9 @@ 96E09DC71F2287E50000B121 /* TabsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabsController.swift; sourceTree = ""; }; 96E3C3931D397AE90086A024 /* Material+UIView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Material+UIView.swift"; sourceTree = ""; }; 96F1DC871D654FDF0025F925 /* Material+CALayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Material+CALayer.swift"; sourceTree = ""; }; + 9DF352411FED20C900B2A11B /* BaseIconLayerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseIconLayerButton.swift; sourceTree = ""; }; + 9DF352431FED20ED00B2A11B /* RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButton.swift; sourceTree = ""; }; + 9DF352451FED210000B2A11B /* CheckButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckButton.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -606,6 +612,9 @@ 96BCB7601CB40DC500C806FE /* FlatButton.swift */, 96BCB7931CB40DC500C806FE /* RaisedButton.swift */, 9658F2161CD6FA4700B902C1 /* IconButton.swift */, + 9DF352411FED20C900B2A11B /* BaseIconLayerButton.swift */, + 9DF352431FED20ED00B2A11B /* RadioButton.swift */, + 9DF352451FED210000B2A11B /* CheckButton.swift */, ); name = Button; sourceTree = ""; @@ -916,6 +925,7 @@ buildActionMask = 2147483647; files = ( 961E6BE21DDA2AF3004E6C93 /* Screen.swift in Sources */, + 9DF352441FED20ED00B2A11B /* RadioButton.swift in Sources */, 965E81261DD4D7C800D61E4B /* Material+NSMutableAttributedString.swift in Sources */, 965E80FF1DD4D5C800D61E4B /* BottomNavigationController.swift in Sources */, 965E81031DD4D5C800D61E4B /* CollectionView.swift in Sources */, @@ -923,6 +933,7 @@ 965E81071DD4D5C800D61E4B /* CollectionViewLayout.swift in Sources */, 965E81081DD4D5C800D61E4B /* CollectionReusableView.swift in Sources */, 965E81091DD4D5C800D61E4B /* DataSourceItem.swift in Sources */, + 9DF352461FED210000B2A11B /* CheckButton.swift in Sources */, 965E810A1DD4D5C800D61E4B /* Font.swift in Sources */, 965E810B1DD4D5C800D61E4B /* RobotoFont.swift in Sources */, 965E810C1DD4D5C800D61E4B /* DynamicFontType.swift in Sources */, @@ -955,6 +966,7 @@ 965E80ED1DD4C55200D61E4B /* Material+UIWindow.swift in Sources */, 961527B91F3A509900E8B2AC /* ChipBarController.swift in Sources */, 965E80E41DD4C53300D61E4B /* PulseView.swift in Sources */, + 9DF352421FED20C900B2A11B /* BaseIconLayerButton.swift in Sources */, 966C17731F0439F600D3E83C /* Material+MotionAnimation.swift in Sources */, 965E80E51DD4C53300D61E4B /* PulseAnimation.swift in Sources */, 965E80FE1DD4D59500D61E4B /* ToolbarController.swift in Sources */, diff --git a/Sources/iOS/BaseIconLayerButton.swift b/Sources/iOS/BaseIconLayerButton.swift new file mode 100644 index 000000000..33a6559dd --- /dev/null +++ b/Sources/iOS/BaseIconLayerButton.swift @@ -0,0 +1,203 @@ +// +// BaseIconLayerButton.swift +// Material +// +// Created by Orkhan Alikhanov on 12/22/18. +// Copyright © 2017 CosmicMind. All rights reserved. +// + +import UIKit +import Motion + +/// Implements common logic for CheckButton and RadioButton +open class BaseIconLayerButton: Button { + class var iconLayer: BaseIconLayer { fatalError("Has to be implemented by subclasses") } + lazy var iconLayer: BaseIconLayer = { return type(of: self).iconLayer }() + + open override var isSelected: Bool { + didSet { + iconLayer.setSelected(isSelected, animated: false) + } + } + + open var normalIconColor: UIColor { + get { + return iconLayer.normalColor + } + set { + iconLayer.normalColor = newValue + } + } + + open var selectedIconColor: UIColor { + get { + return iconLayer.selectedColor + } + set { + iconLayer.selectedColor = newValue + } + } + + open var isAnimating: Bool { return iconLayer.isAnimating } + open func setSelected(_ isSelected: Bool, animated: Bool) { + guard !isAnimating else { return } + iconLayer.setSelected(isSelected, animated: animated) + self.isSelected = isSelected + } + + open override func prepare() { + super.prepare() + layer.addSublayer(iconLayer) + + // we push the title to the right to make room for iconLayer + // `contentEdgeInsets` is used to let default implementation of + // `intrinsicContentSize` consider our titleSpacing + let titleSpacing = (margin + iconSize + margin) / 2 + titleEdgeInsets = UIEdgeInsets(top: 0, left: titleSpacing, bottom: 0, right: -titleSpacing) + contentEdgeInsets = UIEdgeInsets(top: margin, left: titleSpacing, bottom: margin, right: titleSpacing) + } + + open override func touchesBegan(_ touches: Set, with event: UIEvent?) { + // pulse.animation set to .none so that when we call `super.touchesBegan` + // pulse will not expand as there is a `guard` against .none case + pulse.animation = .none + super.touchesBegan(touches, with: event) + pulse.animation = .point + + // expand pulse from the center of iconLayer/visualLayer (`point` is relative to self.view/self.layer) + pulse.expand(point: iconLayer.frame.center) + } + + open override func layoutSubviews() { + super.layoutSubviews() + + // positioning iconLayer + iconLayer.frame.size = CGSize(width: iconSize, height: iconSize) + iconLayer.frame.origin.y = bounds.height / 2 - iconSize / 2 + iconLayer.frame.origin.x = margin + + + // visualLayer is the layer where pulse layer is expanding. + // So we position it at the center of iconLayer, and make it + // small circle, so that the expansion of pulse layer is clipped off + let s = margin + iconSize + margin // considering margin as well + visualLayer.bounds.size = CGSize(width: s, height: s) + visualLayer.frame.center = iconLayer.frame.center + visualLayer.cornerRadius = s / 2 + } + + private let margin: CGFloat = 5 + private let iconSize: CGFloat = 16 +} + +// MARK: - BaseIconLayer + +internal class BaseIconLayer: CALayer { + var selectedColor = Color.blue.base + var normalColor = Color.lightGray + + + func prepareForFirstAnimation() {} + func firstAnimation() {} + + func prepareForSecondAnimation() {} + func secondAnimation() {} + + var isAnimating = false + var isSelected = false + + override init() { + super.init() + prepare() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + prepare() + } + + func prepare() { + normalColor = { normalColor }() // calling didSet + selectedColor = { selectedColor }() // calling didSet + } + + func setSelected(_ isSelected: Bool, animated: Bool) { + guard self.isSelected != isSelected, !isAnimating else { return } + self.isSelected = isSelected + + if animated { + animate() + } else { + Motion.disable { + prepareForFirstAnimation() + firstAnimation() + prepareForSecondAnimation() + secondAnimation() + } + } + } + + private func animate() { + guard !isAnimating else { return } + + prepareForFirstAnimation() + Motion.animate(duration: partialDuration, timingFunction: .easeInOut, animations: { + self.isAnimating = true + self.firstAnimation() + }, completion: { + Motion.disable { + self.prepareForSecondAnimation() + } + Motion.delay(self.partialDuration * self.delayFactor) { + Motion.animate(duration: self.partialDuration, timingFunction: .easeInOut, animations: { + self.secondAnimation() + }, completion: { self.isAnimating = false }) + } + }) + } + + var sideLength: CGFloat { return frame.height } + let totalDuration = 0.5 + private let delayFactor = 0.33 + private var partialDuration: TimeInterval { return totalDuration / (1.0 + delayFactor + 1.0) } +} + +// MARK: - Helper extension + +private extension CGRect { + var center: CGPoint { + get { + return CGPoint(x: minX + width / 2 , y: minY + height / 2) + } + set { + origin = CGPoint(x: newValue.x - width / 2, y: newValue.y - height / 2) + } + } +} + + +internal extension CALayer { + /// Animates the propery of CALayer from current value to the specified value + /// and does not reset to the initial value after the animation finishes + /// + /// - Parameters: + /// - keyPath: Keypath to the animatable property of the layer + /// - to: Final value of the property + /// - dur: Duration of the animation in seconds. Defaults to 0, which results in taking the duration from enclosing CATransaction, or .25 seconds + func animate(_ keyPath: String, to: CGFloat, dur: TimeInterval = 0) { + let animation = CABasicAnimation(keyPath: keyPath) + animation.timingFunction = .easeIn + animation.fromValue = self.value(forKey: keyPath) // from current value + animation.duration = dur + + setValue(to, forKeyPath: keyPath) + self.add(animation, forKey: nil) + } +} + +internal extension CATransform3D { + static var identity: CATransform3D { + return CATransform3DIdentity + } +} + diff --git a/Sources/iOS/CheckButton.swift b/Sources/iOS/CheckButton.swift new file mode 100644 index 000000000..125119fec --- /dev/null +++ b/Sources/iOS/CheckButton.swift @@ -0,0 +1,179 @@ +// +// CheckButton.swift +// Material +// +// Created by Orkhan Alikhanov on 12/22/18. +// Copyright © 2017 CosmicMind. All rights reserved. +// + +import UIKit + +open class CheckButton: BaseIconLayerButton { + class override var iconLayer: BaseIconLayer { return CheckBoxLayer() } + + open var checkmarkColor: UIColor { + get { + return (iconLayer as! CheckBoxLayer).checkmarkColor + } + set { + (iconLayer as! CheckBoxLayer).checkmarkColor = newValue + } + } + + open override func prepare() { + super.prepare() + addTarget(self, action: #selector(didTap), for: .touchUpInside) + } + + @objc + private func didTap() { + guard !isAnimating else { return } + setSelected(!isSelected, animated: true) + } +} + +internal class CheckBoxLayer: BaseIconLayer { + var checkmarkColor: UIColor = .white + let borderLayer = CALayer() + let checkMarkLeftLayer = CAShapeLayer() + let checkMarkRightLayer = CAShapeLayer() + let checkMarkLayer = CALayer() + + override var selectedColor: UIColor { + didSet { + guard isSelected else { return } + borderLayer.borderColor = selectedColor.cgColor + } + } + + override var normalColor: UIColor { + didSet { + guard !isSelected else { return } + borderLayer.borderColor = normalColor.cgColor + } + } + + open override func prepare() { + super.prepare() + addSublayer(borderLayer) + addSublayer(checkMarkLayer) + checkMarkLayer.addSublayer(checkMarkLeftLayer) + checkMarkLayer.addSublayer(checkMarkRightLayer) + checkMarkLeftLayer.lineCap = kCALineCapSquare + checkMarkRightLayer.lineCap = kCALineCapSquare + } + + override func prepareForFirstAnimation() { + borderLayer.borderColor = (isSelected ? selectedColor : normalColor).cgColor + if isSelected { + borderLayer.borderWidth = borderLayerNormalBorderWidth + } else { + borderLayer.backgroundColor = normalColor.cgColor + checkMarkLeftLayer.strokeEnd = 1 + checkMarkRightLayer.strokeEnd = 1 + } + checkMarkLayer.transform = .identity + } + + override func firstAnimation() { + borderLayer.transform = borderLayerScaleToShrink + checkMarkLayer.transform = borderLayerScaleToShrink + if isSelected { + borderLayer.animate(#keyPath(CALayer.borderWidth), to: borderLayerFullBorderWidth) + } else { + checkMarkLeftLayer.animate(#keyPath(CAShapeLayer.strokeEnd), to: 0) + checkMarkRightLayer.animate(#keyPath(CAShapeLayer.strokeEnd), to: 0) + + checkMarkLayer.transform = CATransform3DMakeTranslation(sideLength / 2 - checkMarkStartPoint.x, -(checkMarkStartPoint.y - sideLength / 2), 0) + } + } + + override func prepareForSecondAnimation() { + borderLayer.backgroundColor = (isSelected ? selectedColor : .clear).cgColor + + if isSelected { + borderLayer.borderWidth = borderLayerNormalBorderWidth + checkMarkLeftLayer.strokeEnd = 0.0001 + checkMarkRightLayer.strokeEnd = 0.0001 + + checkMarkLayer.opacity = 0 + checkMarkLayer.animate(#keyPath(CALayer.opacity), to: 1, dur: totalDuration * 0.1) + + checkMarkLeftLayer.strokeColor = checkmarkColor.cgColor + checkMarkRightLayer.strokeColor = checkmarkColor.cgColor + checkMarkLeftLayer.path = checkMarkPathLeft.cgPath + checkMarkRightLayer.path = checkMarkPathRigth.cgPath + checkMarkLeftLayer.lineWidth = lineWidth + checkMarkRightLayer.lineWidth = lineWidth + + checkMarkLeftLayer.strokeEnd = 0 + checkMarkRightLayer.strokeEnd = 0 + } else { + borderLayer.borderWidth = borderLayerCenterDotBorderWidth + } + } + + override func secondAnimation() { + borderLayer.transform = .identity + checkMarkLayer.transform = .identity + if isSelected { + checkMarkLeftLayer.animate(#keyPath(CAShapeLayer.strokeEnd), to: 1) + checkMarkRightLayer.animate(#keyPath(CAShapeLayer.strokeEnd), to: 1) + } else { + borderLayer.animate(#keyPath(CALayer.borderWidth), to: borderLayerNormalBorderWidth) + } + } + + override func layoutSublayers() { + super.layoutSublayers() + guard !isAnimating else { return } + + borderLayer.frame.size = CGSize(width: sideLength, height: sideLength) + checkMarkLayer.frame.size = borderLayer.frame.size + checkMarkLeftLayer.frame.size = borderLayer.frame.size + checkMarkRightLayer.frame.size = borderLayer.frame.size + + borderLayer.borderWidth = borderLayerNormalBorderWidth + borderLayer.cornerRadius = borderLayerCornerRadius + } +} + +private extension CheckBoxLayer { + var borderLayerFullBorderWidth: CGFloat { return sideLength / 2 * 1.1 } //without multipling 1.1 a weird plus sign (+) appears sometimes. + var borderLayerCenterDotBorderWidth: CGFloat { return sideLength / 2 * 0.87 } + var borderLayerNormalBorderWidth: CGFloat { return sideLength * 0.1 } + var borderLayerCornerRadius: CGFloat { return sideLength * 0.1 } + var borderLayerScalePercentageToShrink: CGFloat { return 0.9 } + var borderLayerScaleToShrink: CATransform3D { + return CATransform3DMakeScale(borderLayerScalePercentageToShrink, borderLayerScalePercentageToShrink, 1) + } + + + var checkMarkStartPoint: CGPoint { + return CGPoint(x: sideLength * 14 / 36, y: sideLength * 25 / 36) + } + + var checkMarkRightEndPoint: CGPoint { + return CGPoint(x: sideLength - (sideLength * 6 / 36), y: sideLength * 9 / 36) + } + + var checkMarkLeftEndPoint: CGPoint { + return CGPoint(x: sideLength * 6 / 36, y: sideLength * 18 / 36) + } + + var checkMarkPathRigth: UIBezierPath { + let path = UIBezierPath() + path.move(to: checkMarkStartPoint) + path.addLine(to: checkMarkRightEndPoint) + return path + } + + var checkMarkPathLeft: UIBezierPath { + let path = UIBezierPath() + path.move(to: checkMarkStartPoint) + path.addLine(to: checkMarkLeftEndPoint) + return path + } + + var lineWidth: CGFloat { return sideLength * 0.1 } +} diff --git a/Sources/iOS/RadioButton.swift b/Sources/iOS/RadioButton.swift new file mode 100644 index 000000000..afa3b6286 --- /dev/null +++ b/Sources/iOS/RadioButton.swift @@ -0,0 +1,114 @@ +// +// RadioButton.swift +// Material +// +// Created by Orkhan Alikhanov on 12/22/18. +// Copyright © 2017 CosmicMind. All rights reserved. +// + +import UIKit + +open class RadioButton: BaseIconLayerButton { + class override var iconLayer: BaseIconLayer { return RadioBoxLayer() } + + open override func prepare() { + super.prepare() + + addTarget(self, action: #selector(didTap), for: .touchUpInside) + } + + @objc + private func didTap() { + setSelected(true, animated: true) + } +} + +internal class RadioBoxLayer: BaseIconLayer { + private let centerDot = CALayer() + private let outerCircle = CALayer() + + override var selectedColor: UIColor { + didSet { + guard isSelected else { return } + outerCircle.borderColor = selectedColor.cgColor + centerDot.backgroundColor = selectedColor.cgColor + } + } + + override var normalColor: UIColor { + didSet { + if !isSelected { + outerCircle.borderColor = normalColor.cgColor + } + } + } + + override func prepare() { + super.prepare() + addSublayer(centerDot) + addSublayer(outerCircle) + } + + override func prepareForFirstAnimation() { + outerCircle.borderColor = (isSelected ? selectedColor : normalColor).cgColor + if !isSelected { + centerDot.backgroundColor = normalColor.cgColor + } + outerCircle.borderWidth = outerCircleBorderWidth + } + + override func firstAnimation() { + outerCircle.transform = outerCircleScaleToShrink + let to = isSelected ? sideLength / 2.0 : outerCircleBorderWidth * percentageOfOuterCircleWidthToStart + outerCircle.animate(#keyPath(CALayer.borderWidth), to: to) + if !isSelected { + centerDot.transform = centerDotScaleForMeeting + } + } + + override func prepareForSecondAnimation() { + centerDot.transform = isSelected ? centerDotScaleForMeeting : .identity + centerDot.backgroundColor = (isSelected ? selectedColor : .clear).cgColor + outerCircle.borderWidth = isSelected ? outerCircleBorderWidth * percentageOfOuterCircleWidthToStart : outerCircleFullBorderWidth + } + + override func secondAnimation() { + outerCircle.transform = .identity + outerCircle.animate(#keyPath(CALayer.borderWidth), to: outerCircleBorderWidth) + if isSelected { + centerDot.transform = .identity + } + } + + override func layoutSublayers() { + super.layoutSublayers() + guard !isAnimating else { return } + + centerDot.frame = CGRect(x: centerDotDiameter / 2.0, y: centerDotDiameter / 2.0, width: centerDotDiameter, height: centerDotDiameter) + outerCircle.frame.size = CGSize(width: sideLength, height: sideLength) + centerDot.cornerRadius = centerDot.bounds.width / 2 + outerCircle.cornerRadius = sideLength / 2 + outerCircle.borderWidth = outerCircleBorderWidth + } +} + +private extension RadioBoxLayer { + var percentageOfOuterCircleSizeToShrinkTo: CGFloat { return 0.9 } + var percentageOfOuterCircleWidthToStart: CGFloat { return 1 } + + var outerCircleScaleToShrink: CATransform3D { + let s = percentageOfOuterCircleSizeToShrinkTo + return CATransform3DMakeScale(s, s, 1) + } + var centerDotScaleForMeeting: CATransform3D { + let s = ((sideLength - 2 * percentageOfOuterCircleWidthToStart * outerCircleBorderWidth) * percentageOfOuterCircleSizeToShrinkTo) / centerDotDiameter + return CATransform3DMakeScale(s, s, 1) + } + + var outerCircleFullBorderWidth: CGFloat { + return (self.sideLength / 2.0) * 1.1 //without multipling 1.1 a weird plus sign (+) appears sometimes. + } + + var centerDotDiameter: CGFloat { return sideLength / 2.0 } + var outerCircleBorderWidth: CGFloat { return sideLength * 0.11 } +} From bdacb4a0dbfbdd8bf44ba035b68772922bf7e450 Mon Sep 17 00:00:00 2001 From: Orkhan Alikhanov Date: Sun, 24 Dec 2017 22:11:37 +0400 Subject: [PATCH 2/9] Added RadioButtonGroup and CheckButtonGroup --- Material.xcodeproj/project.pbxproj | 20 ++++++++ Sources/iOS/BaseButtonGroup.swift | 73 ++++++++++++++++++++++++++++++ Sources/iOS/CheckButtonGroup.swift | 27 +++++++++++ Sources/iOS/RadioButtonGroup.swift | 30 ++++++++++++ 4 files changed, 150 insertions(+) create mode 100644 Sources/iOS/BaseButtonGroup.swift create mode 100644 Sources/iOS/CheckButtonGroup.swift create mode 100644 Sources/iOS/RadioButtonGroup.swift diff --git a/Material.xcodeproj/project.pbxproj b/Material.xcodeproj/project.pbxproj index 1324df752..d61b5898d 100644 --- a/Material.xcodeproj/project.pbxproj +++ b/Material.xcodeproj/project.pbxproj @@ -174,6 +174,9 @@ 96E3C39A1D3A1CC20086A024 /* ErrorTextField.swift in Headers */ = {isa = PBXBuildFile; fileRef = 961F18E71CD93E3E008927C5 /* ErrorTextField.swift */; settings = {ATTRIBUTES = (Public, ); }; }; 96E3C39C1D3A1CC20086A024 /* Offset.swift in Headers */ = {isa = PBXBuildFile; fileRef = 968C99461D377849000074FF /* Offset.swift */; settings = {ATTRIBUTES = (Public, ); }; }; 96F1A5531F24F17A001D8CAF /* TabsController.swift in Headers */ = {isa = PBXBuildFile; fileRef = 96E09DC71F2287E50000B121 /* TabsController.swift */; settings = {ATTRIBUTES = (Public, ); }; }; + 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 */; }; 9DF352421FED20C900B2A11B /* BaseIconLayerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DF352411FED20C900B2A11B /* BaseIconLayerButton.swift */; }; 9DF352441FED20ED00B2A11B /* RadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DF352431FED20ED00B2A11B /* RadioButton.swift */; }; 9DF352461FED210000B2A11B /* CheckButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DF352451FED210000B2A11B /* CheckButton.swift */; }; @@ -288,6 +291,9 @@ 96E09DC71F2287E50000B121 /* TabsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabsController.swift; sourceTree = ""; }; 96E3C3931D397AE90086A024 /* Material+UIView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Material+UIView.swift"; sourceTree = ""; }; 96F1DC871D654FDF0025F925 /* Material+CALayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Material+CALayer.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 = ""; }; 9DF352411FED20C900B2A11B /* BaseIconLayerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseIconLayerButton.swift; sourceTree = ""; }; 9DF352431FED20ED00B2A11B /* RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButton.swift; sourceTree = ""; }; 9DF352451FED210000B2A11B /* CheckButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckButton.swift; sourceTree = ""; }; @@ -523,6 +529,7 @@ 96264BE41D833C8400576F37 /* Bar */, 962DDD081D6FBBD0001C307C /* BottomTabBar */, 96BCB8031CB40F4B00C806FE /* Button */, + 9DE84D6E1FF0250E00586C8B /* ButtonGroup */, 96BCB8021CB40F3B00C806FE /* Card */, 961154CA1F32999000A78D74 /* Chip */, 96BCB8051CB40F9C00C806FE /* Collection */, @@ -738,6 +745,16 @@ name = Animation; sourceTree = ""; }; + 9DE84D6E1FF0250E00586C8B /* ButtonGroup */ = { + isa = PBXGroup; + children = ( + 9DE84D701FF0252500586C8B /* BaseButtonGroup.swift */, + 9DE84D6F1FF0252500586C8B /* RadioButtonGroup.swift */, + 9DE84D711FF0252500586C8B /* CheckButtonGroup.swift */, + ); + name = ButtonGroup; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -948,6 +965,7 @@ 965E81181DD4D5C800D61E4B /* Snackbar.swift in Sources */, 965E81191DD4D5C800D61E4B /* SnackbarController.swift in Sources */, 9618006D1F4D384200CD77A1 /* Material+UIViewController.swift in Sources */, + 9DE84D741FF0252600586C8B /* CheckButtonGroup.swift in Sources */, 965E811A1DD4D5C800D61E4B /* StatusBarController.swift in Sources */, 965E811B1DD4D5C800D61E4B /* Switch.swift in Sources */, 965E811C1DD4D5C800D61E4B /* TabBar.swift in Sources */, @@ -959,6 +977,7 @@ 965E80E71DD4C55200D61E4B /* Material+UIView.swift in Sources */, 965E80E81DD4C55200D61E4B /* Material+CALayer.swift in Sources */, 965E80E91DD4C55200D61E4B /* Material+String.swift in Sources */, + 9DE84D731FF0252600586C8B /* BaseButtonGroup.swift in Sources */, 965E80F71DD4D59500D61E4B /* Card.swift in Sources */, 965E80EA1DD4C55200D61E4B /* Material+UIFont.swift in Sources */, 965E80EB1DD4C55200D61E4B /* Material+UIImage.swift in Sources */, @@ -969,6 +988,7 @@ 9DF352421FED20C900B2A11B /* BaseIconLayerButton.swift in Sources */, 966C17731F0439F600D3E83C /* Material+MotionAnimation.swift in Sources */, 965E80E51DD4C53300D61E4B /* PulseAnimation.swift in Sources */, + 9DE84D721FF0252600586C8B /* RadioButtonGroup.swift in Sources */, 965E80FE1DD4D59500D61E4B /* ToolbarController.swift in Sources */, 96328B971E05C0BB009A4C90 /* TableView.swift in Sources */, 965E80F81DD4D59500D61E4B /* ImageCard.swift in Sources */, diff --git a/Sources/iOS/BaseButtonGroup.swift b/Sources/iOS/BaseButtonGroup.swift new file mode 100644 index 000000000..6c8ccdf0c --- /dev/null +++ b/Sources/iOS/BaseButtonGroup.swift @@ -0,0 +1,73 @@ +// +// BaseButtonGroup.swift +// Material +// +// Created by Orkhan Alikhanov on 12/24/17. +// Copyright © 2017 CosmicMind, Inc. All rights reserved. +// + +open class BaseButtonGroup: View { + open var buttons: [T] = [] { + didSet { + oldValue.forEach { + $0.removeFromSuperview() + $0.removeTarget(self, action: #selector(didTap(_:)), for: .touchUpInside) + } + prepareButtons() + grid.views = buttons + grid.axis.rows = buttons.count + } + } + + public convenience init(buttons: [T]) { + self.init(frame: .zero) + defer { self.buttons = buttons } // defer allows didSet to be called + } + + open override func prepare() { + super.prepare() + grid.axis.direction = .vertical + grid.axis.columns = 1 + } + + + open override var intrinsicContentSize: CGSize { return sizeThatFits(bounds.size) } + + open override func sizeThatFits(_ size: CGSize) -> CGSize { + let size = CGSize(width: size.width == 0 ? .greatestFiniteMagnitude : size.width, height: size.height == 0 ? .greatestFiniteMagnitude : size.height) + let availableW = size.width - grid.contentEdgeInsets.left - grid.contentEdgeInsets.right - grid.layoutEdgeInsets.left - grid.layoutEdgeInsets.right + var maxW: CGFloat = 0 + buttons.forEach { maxW = max(maxW, $0.sizeThatFits(.init(width: availableW, height: .greatestFiniteMagnitude)).width) } + + var h = grid.contentEdgeInsets.top + grid.contentEdgeInsets.bottom + grid.layoutEdgeInsets.top + grid.layoutEdgeInsets.bottom + CGFloat(buttons.count - 1) * grid.interimSpace + buttons.forEach { h += $0.sizeThatFits(.init(width: maxW, height: .greatestFiniteMagnitude)).height } + + return CGSize(width: maxW + grid.contentEdgeInsets.left + grid.contentEdgeInsets.right + grid.layoutEdgeInsets.left + grid.layoutEdgeInsets.right, height: min(h, size.height)) + } + + open override func layoutSubviews() { + super.layoutSubviews() + grid.reload() + } + + open func didTap(button: T, at index: Int) { } + + @objc + private func didTap(_ sender: Button) { + guard let sender = sender as? T, + let index = buttons.index(of: sender) + else { return } + + didTap(button: sender, at: index) + } +} + +private extension BaseButtonGroup { + func prepareButtons() { + buttons.forEach { + addSubview($0) + $0.removeTarget(nil, action: nil, for: .allEvents) + $0.addTarget(self, action: #selector(didTap(_:)), for: .touchUpInside) + } + } +} diff --git a/Sources/iOS/CheckButtonGroup.swift b/Sources/iOS/CheckButtonGroup.swift new file mode 100644 index 000000000..6a9dad848 --- /dev/null +++ b/Sources/iOS/CheckButtonGroup.swift @@ -0,0 +1,27 @@ +// +// CheckButtonGroup.swift +// Material +// +// Created by Orkhan Alikhanov on 12/24/17. +// Copyright © 2017 CosmicMind, Inc. All rights reserved. +// + +open class CheckButtonGroup: BaseButtonGroup { + public convenience init(titles: [String]) { + let buttons = titles.map { CheckButton(title: $0) } + self.init(buttons: buttons) + } + + open var selecetedButtons: [CheckButton] { + return buttons.filter { $0.isSelected } + } + + open var selectedIndices: [Int] { + return selecetedButtons.map { buttons.index(of: $0)! } + } + + open override func didTap(button: CheckButton, at index: Int) { + button.setSelected(!button.isSelected, animated: true) + } +} + diff --git a/Sources/iOS/RadioButtonGroup.swift b/Sources/iOS/RadioButtonGroup.swift new file mode 100644 index 000000000..5b5e59938 --- /dev/null +++ b/Sources/iOS/RadioButtonGroup.swift @@ -0,0 +1,30 @@ +// +// RadioButtonGroup.swift +// Material +// +// Created by Orkhan Alikhanov on 12/24/17. +// Copyright © 2017 CosmicMind, Inc. All rights reserved. +// + +open class RadioButtonGroup: BaseButtonGroup { + public convenience init(titles: [String]) { + let buttons = titles.map { RadioButton(title: $0) } + self.init(buttons: buttons) + } + + open var selectedButton: RadioButton? { + return buttons.first { $0.isSelected } + } + + open var selectedIndex: Int { + guard let b = selectedButton else { return -1 } + return buttons.index(of: b)! + } + + open override func didTap(button: RadioButton, at index: Int) { + let isAnimating = buttons.reduce(false) { $0 || $1.isAnimating } + guard !isAnimating else { return } + + buttons.forEach { $0.setSelected($0 == button, animated: true) } + } +} From 2d066026c785f6b85f625d0bcbce25907d1c7897 Mon Sep 17 00:00:00 2001 From: Orkhan Alikhanov Date: Mon, 25 Dec 2017 16:15:35 +0400 Subject: [PATCH 3/9] Fixed checkmarkColor not being reflected immediately --- Sources/iOS/CheckButton.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/iOS/CheckButton.swift b/Sources/iOS/CheckButton.swift index 125119fec..80b4728c6 100644 --- a/Sources/iOS/CheckButton.swift +++ b/Sources/iOS/CheckButton.swift @@ -33,7 +33,12 @@ open class CheckButton: BaseIconLayerButton { } internal class CheckBoxLayer: BaseIconLayer { - var checkmarkColor: UIColor = .white + var checkmarkColor: UIColor = .white { + didSet { + checkMarkLeftLayer.strokeColor = checkmarkColor.cgColor + checkMarkRightLayer.strokeColor = checkmarkColor.cgColor + } + } let borderLayer = CALayer() let checkMarkLeftLayer = CAShapeLayer() let checkMarkRightLayer = CAShapeLayer() From 6215469e112570218e1d39ebf488c256778c30b6 Mon Sep 17 00:00:00 2001 From: Orkhan Alikhanov Date: Mon, 25 Dec 2017 16:26:05 +0400 Subject: [PATCH 4/9] Fixed laying out checkmark sign incorrectly Before this commit, cgPath and lineWidth of the check mark layer were only updated on the change of selected state. Fixed to react to size changes made to the parent layer by iOS framework Fixes bug when CheckButton is changed to have selected state when it has no size (CGSize.zero) --- Sources/iOS/CheckButton.swift | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/Sources/iOS/CheckButton.swift b/Sources/iOS/CheckButton.swift index 80b4728c6..40467f134 100644 --- a/Sources/iOS/CheckButton.swift +++ b/Sources/iOS/CheckButton.swift @@ -66,6 +66,9 @@ internal class CheckBoxLayer: BaseIconLayer { checkMarkLayer.addSublayer(checkMarkRightLayer) checkMarkLeftLayer.lineCap = kCALineCapSquare checkMarkRightLayer.lineCap = kCALineCapSquare + checkMarkLeftLayer.strokeEnd = 0 + checkMarkRightLayer.strokeEnd = 0 + checkmarkColor = { checkmarkColor }() // calling didSet } override func prepareForFirstAnimation() { @@ -103,16 +106,6 @@ internal class CheckBoxLayer: BaseIconLayer { checkMarkLayer.opacity = 0 checkMarkLayer.animate(#keyPath(CALayer.opacity), to: 1, dur: totalDuration * 0.1) - - checkMarkLeftLayer.strokeColor = checkmarkColor.cgColor - checkMarkRightLayer.strokeColor = checkmarkColor.cgColor - checkMarkLeftLayer.path = checkMarkPathLeft.cgPath - checkMarkRightLayer.path = checkMarkPathRigth.cgPath - checkMarkLeftLayer.lineWidth = lineWidth - checkMarkRightLayer.lineWidth = lineWidth - - checkMarkLeftLayer.strokeEnd = 0 - checkMarkRightLayer.strokeEnd = 0 } else { borderLayer.borderWidth = borderLayerCenterDotBorderWidth } @@ -133,10 +126,16 @@ internal class CheckBoxLayer: BaseIconLayer { super.layoutSublayers() guard !isAnimating else { return } - borderLayer.frame.size = CGSize(width: sideLength, height: sideLength) - checkMarkLayer.frame.size = borderLayer.frame.size - checkMarkLeftLayer.frame.size = borderLayer.frame.size - checkMarkRightLayer.frame.size = borderLayer.frame.size + let s = CGSize(width: sideLength, height: sideLength) + borderLayer.frame.size = s + checkMarkLayer.frame.size = s + checkMarkLeftLayer.frame.size = s + checkMarkRightLayer.frame.size = s + + checkMarkLeftLayer.path = checkMarkPathLeft.cgPath + checkMarkRightLayer.path = checkMarkPathRigth.cgPath + checkMarkLeftLayer.lineWidth = lineWidth + checkMarkRightLayer.lineWidth = lineWidth borderLayer.borderWidth = borderLayerNormalBorderWidth borderLayer.cornerRadius = borderLayerCornerRadius From 28ee6886a135c24c5b323b0b9a98fbe35093a155 Mon Sep 17 00:00:00 2001 From: Orkhan Alikhanov Date: Mon, 25 Dec 2017 16:39:12 +0400 Subject: [PATCH 5/9] Fixed setting selectedColor of check button incorrectly --- Sources/iOS/CheckButton.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/iOS/CheckButton.swift b/Sources/iOS/CheckButton.swift index 40467f134..5b707e319 100644 --- a/Sources/iOS/CheckButton.swift +++ b/Sources/iOS/CheckButton.swift @@ -48,6 +48,7 @@ internal class CheckBoxLayer: BaseIconLayer { didSet { guard isSelected else { return } borderLayer.borderColor = selectedColor.cgColor + borderLayer.backgroundColor = selectedColor.cgColor } } From edfe804643e659e917bc97de4a2d4b20bfac95b0 Mon Sep 17 00:00:00 2001 From: Orkhan Alikhanov Date: Mon, 25 Dec 2017 18:09:34 +0400 Subject: [PATCH 6/9] Considered disabled state for CheckButton/RadioButton --- Sources/iOS/BaseIconLayerButton.swift | 50 +++++++++++++++++++-------- Sources/iOS/CheckButton.swift | 18 +++++++--- Sources/iOS/RadioButton.swift | 21 +++++++---- 3 files changed, 63 insertions(+), 26 deletions(-) diff --git a/Sources/iOS/BaseIconLayerButton.swift b/Sources/iOS/BaseIconLayerButton.swift index 33a6559dd..30eb50a3e 100644 --- a/Sources/iOS/BaseIconLayerButton.swift +++ b/Sources/iOS/BaseIconLayerButton.swift @@ -20,21 +20,35 @@ open class BaseIconLayerButton: Button { } } - open var normalIconColor: UIColor { - get { - return iconLayer.normalColor - } - set { - iconLayer.normalColor = newValue + open override var isEnabled: Bool { + didSet { + iconLayer.isEnabled = isEnabled } } - open var selectedIconColor: UIColor { - get { - return iconLayer.selectedColor + open func setIconColor(_ color: UIColor, for state: UIControlState) { + switch state { + case .normal: + iconLayer.normalColor = color + case .selected: + iconLayer.selectedColor = color + case .disabled: + iconLayer.disabledColor = color + default: + fatalError("unsupported state") } - set { - iconLayer.selectedColor = newValue + } + + open func iconColor(for state: UIControlState) -> UIColor { + switch state { + case .normal: + return iconLayer.normalColor + case .selected: + return iconLayer.selectedColor + case .disabled: + return iconLayer.disabledColor + default: + fatalError("unsupported state") } } @@ -95,6 +109,7 @@ open class BaseIconLayerButton: Button { internal class BaseIconLayer: CALayer { var selectedColor = Color.blue.base var normalColor = Color.lightGray + var disabledColor = Color.gray func prepareForFirstAnimation() {} @@ -103,8 +118,15 @@ internal class BaseIconLayer: CALayer { func prepareForSecondAnimation() {} func secondAnimation() {} - var isAnimating = false - var isSelected = false + private(set) var isAnimating = false + private(set) var isSelected = false + var isEnabled = true { + didSet { + selectedColor = { selectedColor }() + normalColor = { normalColor }() + disabledColor = { disabledColor }() + } + } override init() { super.init() @@ -187,7 +209,7 @@ internal extension CALayer { func animate(_ keyPath: String, to: CGFloat, dur: TimeInterval = 0) { let animation = CABasicAnimation(keyPath: keyPath) animation.timingFunction = .easeIn - animation.fromValue = self.value(forKey: keyPath) // from current value + animation.fromValue = self.value(forKeyPath: keyPath) // from current value animation.duration = dur setValue(to, forKeyPath: keyPath) diff --git a/Sources/iOS/CheckButton.swift b/Sources/iOS/CheckButton.swift index 5b707e319..a51037fe4 100644 --- a/Sources/iOS/CheckButton.swift +++ b/Sources/iOS/CheckButton.swift @@ -46,7 +46,7 @@ internal class CheckBoxLayer: BaseIconLayer { override var selectedColor: UIColor { didSet { - guard isSelected else { return } + guard isSelected, isEnabled else { return } borderLayer.borderColor = selectedColor.cgColor borderLayer.backgroundColor = selectedColor.cgColor } @@ -54,11 +54,19 @@ internal class CheckBoxLayer: BaseIconLayer { override var normalColor: UIColor { didSet { - guard !isSelected else { return } + guard !isSelected, isEnabled else { return } borderLayer.borderColor = normalColor.cgColor } } + override var disabledColor: UIColor { + didSet { + guard !isEnabled else { return } + borderLayer.borderColor = disabledColor.cgColor + if isSelected { borderLayer.backgroundColor = disabledColor.cgColor } + } + } + open override func prepare() { super.prepare() addSublayer(borderLayer) @@ -73,11 +81,11 @@ internal class CheckBoxLayer: BaseIconLayer { } override func prepareForFirstAnimation() { - borderLayer.borderColor = (isSelected ? selectedColor : normalColor).cgColor + borderLayer.borderColor = (isEnabled ? (isSelected ? selectedColor : normalColor) : disabledColor).cgColor if isSelected { borderLayer.borderWidth = borderLayerNormalBorderWidth } else { - borderLayer.backgroundColor = normalColor.cgColor + borderLayer.backgroundColor = (isEnabled ? normalColor : disabledColor).cgColor checkMarkLeftLayer.strokeEnd = 1 checkMarkRightLayer.strokeEnd = 1 } @@ -98,7 +106,7 @@ internal class CheckBoxLayer: BaseIconLayer { } override func prepareForSecondAnimation() { - borderLayer.backgroundColor = (isSelected ? selectedColor : .clear).cgColor + borderLayer.backgroundColor = (isSelected ? (isEnabled ? selectedColor : disabledColor) : .clear).cgColor if isSelected { borderLayer.borderWidth = borderLayerNormalBorderWidth diff --git a/Sources/iOS/RadioButton.swift b/Sources/iOS/RadioButton.swift index afa3b6286..c79196f80 100644 --- a/Sources/iOS/RadioButton.swift +++ b/Sources/iOS/RadioButton.swift @@ -29,7 +29,7 @@ internal class RadioBoxLayer: BaseIconLayer { override var selectedColor: UIColor { didSet { - guard isSelected else { return } + guard isSelected, isEnabled else { return } outerCircle.borderColor = selectedColor.cgColor centerDot.backgroundColor = selectedColor.cgColor } @@ -37,12 +37,19 @@ internal class RadioBoxLayer: BaseIconLayer { override var normalColor: UIColor { didSet { - if !isSelected { - outerCircle.borderColor = normalColor.cgColor - } + guard !isSelected, isEnabled else { return } + outerCircle.borderColor = normalColor.cgColor } } + override var disabledColor: UIColor { + didSet { + guard !isEnabled else { return } + outerCircle.borderColor = disabledColor.cgColor + if isSelected { centerDot.backgroundColor = disabledColor.cgColor } + } + } + override func prepare() { super.prepare() addSublayer(centerDot) @@ -50,9 +57,9 @@ internal class RadioBoxLayer: BaseIconLayer { } override func prepareForFirstAnimation() { - outerCircle.borderColor = (isSelected ? selectedColor : normalColor).cgColor + outerCircle.borderColor = (isEnabled ? (isSelected ? selectedColor : normalColor) : disabledColor).cgColor if !isSelected { - centerDot.backgroundColor = normalColor.cgColor + centerDot.backgroundColor = (isEnabled ? normalColor : disabledColor).cgColor } outerCircle.borderWidth = outerCircleBorderWidth } @@ -68,7 +75,7 @@ internal class RadioBoxLayer: BaseIconLayer { override func prepareForSecondAnimation() { centerDot.transform = isSelected ? centerDotScaleForMeeting : .identity - centerDot.backgroundColor = (isSelected ? selectedColor : .clear).cgColor + centerDot.backgroundColor = (isSelected ? (isEnabled ? selectedColor : disabledColor) : .clear).cgColor outerCircle.borderWidth = isSelected ? outerCircleBorderWidth * percentageOfOuterCircleWidthToStart : outerCircleFullBorderWidth } From b8b1b89902aaf48b21f137f7bf1bc5691d44ce4b Mon Sep 17 00:00:00 2001 From: Orkhan Alikhanov Date: Mon, 25 Dec 2017 18:26:10 +0400 Subject: [PATCH 7/9] Added documentation for Radio/Check things --- Sources/iOS/BaseButtonGroup.swift | 7 +++++++ Sources/iOS/BaseIconLayerButton.swift | 26 ++++++++++++++++++++++++-- Sources/iOS/CheckButton.swift | 1 + Sources/iOS/CheckButtonGroup.swift | 16 ++++++++++++++++ Sources/iOS/RadioButtonGroup.swift | 17 +++++++++++++++++ 5 files changed, 65 insertions(+), 2 deletions(-) diff --git a/Sources/iOS/BaseButtonGroup.swift b/Sources/iOS/BaseButtonGroup.swift index 6c8ccdf0c..8464ca54e 100644 --- a/Sources/iOS/BaseButtonGroup.swift +++ b/Sources/iOS/BaseButtonGroup.swift @@ -6,7 +6,11 @@ // Copyright © 2017 CosmicMind, Inc. All rights reserved. // +import UIKit + open class BaseButtonGroup: View { + + /// Holds reference to buttons within the group. open var buttons: [T] = [] { didSet { oldValue.forEach { @@ -19,6 +23,9 @@ open class BaseButtonGroup: View { } } + /// Initializes group with the provided buttons. + /// + /// - Parameter buttons: Array of buttons. public convenience init(buttons: [T]) { self.init(frame: .zero) defer { self.buttons = buttons } // defer allows didSet to be called diff --git a/Sources/iOS/BaseIconLayerButton.swift b/Sources/iOS/BaseIconLayerButton.swift index 30eb50a3e..528cd9a22 100644 --- a/Sources/iOS/BaseIconLayerButton.swift +++ b/Sources/iOS/BaseIconLayerButton.swift @@ -14,18 +14,28 @@ open class BaseIconLayerButton: Button { class var iconLayer: BaseIconLayer { fatalError("Has to be implemented by subclasses") } lazy var iconLayer: BaseIconLayer = { return type(of: self).iconLayer }() + /// A Boolean value indicating whether the button is in the selected state + /// + /// Use `setSelected(_:, animated:)` if the state change needs to be animated open override var isSelected: Bool { didSet { iconLayer.setSelected(isSelected, animated: false) } } + /// A Boolean value indicating whether the control is enabled. open override var isEnabled: Bool { didSet { iconLayer.isEnabled = isEnabled } } + + /// Sets the color of the icon to use for the specified state. + /// + /// - Parameters: + /// - color: The color of the icon to use for the specified state. + /// - state: The state that uses the specified color. Supports only (.normal, .selected, .disabled) open func setIconColor(_ color: UIColor, for state: UIControlState) { switch state { case .normal: @@ -38,7 +48,11 @@ open class BaseIconLayerButton: Button { fatalError("unsupported state") } } - + + /// Returns the icon color used for a state. + /// + /// - Parameter state: The state that uses the icon color. Supports only (.normal, .selected, .disabled) + /// - Returns: The color of the title for the specified state. open func iconColor(for state: UIControlState) -> UIColor { switch state { case .normal: @@ -52,7 +66,15 @@ open class BaseIconLayerButton: Button { } } + /// A Boolean value indicating whether the button is being animated open var isAnimating: Bool { return iconLayer.isAnimating } + + + /// Sets the `selected` state of the button, optionally animating the transition. + /// + /// - Parameters: + /// - isSelected: A Boolean value indicating new `selected` state + /// - animated: true if the state change should be animated, otherwise false. open func setSelected(_ isSelected: Bool, animated: Bool) { guard !isAnimating else { return } iconLayer.setSelected(isSelected, animated: animated) @@ -99,7 +121,7 @@ open class BaseIconLayerButton: Button { visualLayer.frame.center = iconLayer.frame.center visualLayer.cornerRadius = s / 2 } - + private let margin: CGFloat = 5 private let iconSize: CGFloat = 16 } diff --git a/Sources/iOS/CheckButton.swift b/Sources/iOS/CheckButton.swift index a51037fe4..de671636b 100644 --- a/Sources/iOS/CheckButton.swift +++ b/Sources/iOS/CheckButton.swift @@ -11,6 +11,7 @@ import UIKit open class CheckButton: BaseIconLayerButton { class override var iconLayer: BaseIconLayer { return CheckBoxLayer() } + /// Color of the checkmark (✓) open var checkmarkColor: UIColor { get { return (iconLayer as! CheckBoxLayer).checkmarkColor diff --git a/Sources/iOS/CheckButtonGroup.swift b/Sources/iOS/CheckButtonGroup.swift index 6a9dad848..05994236e 100644 --- a/Sources/iOS/CheckButtonGroup.swift +++ b/Sources/iOS/CheckButtonGroup.swift @@ -6,16 +6,32 @@ // Copyright © 2017 CosmicMind, Inc. All rights reserved. // +/// Lays out provided check buttons within itself. +/// +/// Unlike RadioButtonGroup, checking one check button that belongs to a check group *does not* unchecks any previously checked +/// check button within the same group. Intially, all of the check buttons are unchecked. +/// +/// The buttons are layout out by `Grid` system, so that changing properites of grid instance +/// (e.g interimSpace) are reflected. open class CheckButtonGroup: BaseButtonGroup { + + /// Initializes CheckButtonGroup with an array of check buttons each having + /// title equal to corresponding string in the `titles` parameter. + /// + /// - Parameter titles: An array of title strings public convenience init(titles: [String]) { let buttons = titles.map { CheckButton(title: $0) } self.init(buttons: buttons) } + /// Returns all selected check buttons within the group + /// or empty array if none is seleceted. open var selecetedButtons: [CheckButton] { return buttons.filter { $0.isSelected } } + /// Returns indexes of all selected check buttons within the group + /// or empty array if none is seleceted. open var selectedIndices: [Int] { return selecetedButtons.map { buttons.index(of: $0)! } } diff --git a/Sources/iOS/RadioButtonGroup.swift b/Sources/iOS/RadioButtonGroup.swift index 5b5e59938..008880623 100644 --- a/Sources/iOS/RadioButtonGroup.swift +++ b/Sources/iOS/RadioButtonGroup.swift @@ -6,16 +6,33 @@ // Copyright © 2017 CosmicMind, Inc. All rights reserved. // + +/// Lays out provided radio buttons within itself. +/// +/// Checking one radio button that belongs to a radio group unchecks any previously checked +/// radio button within the same group. Intially, all of the radio buttons are unchecked. +/// +/// The buttons are layout out by `Grid` system, so that changing properites of grid instance +/// (e.g interimSpace) are reflected. open class RadioButtonGroup: BaseButtonGroup { + + /// Initializes RadioButtonGroup with an array of radio buttons each having + /// title equal to corresponding string in the `titles` parameter. + /// + /// - Parameter titles: An array of title strings. public convenience init(titles: [String]) { let buttons = titles.map { RadioButton(title: $0) } self.init(buttons: buttons) } + /// Returns selected radio button within the group. + /// If none is selected (e.g in initial state), nil is returned. open var selectedButton: RadioButton? { return buttons.first { $0.isSelected } } + /// Returns index of selected radio button within the group. + /// If none is selected (e.g in initial state), -1 is returned. open var selectedIndex: Int { guard let b = selectedButton else { return -1 } return buttons.index(of: b)! From 9a2f1b36370d15ed79f14f9571242c11ac1b18d6 Mon Sep 17 00:00:00 2001 From: Orkhan Alikhanov Date: Sun, 21 Jan 2018 00:31:53 +0400 Subject: [PATCH 8/9] Refactored radiobutton/checkbutton stuff --- Sources/iOS/BaseButtonGroup.swift | 9 +++++---- Sources/iOS/BaseIconLayerButton.swift | 17 ++++++++++------- Sources/iOS/CheckButton.swift | 2 +- Sources/iOS/CheckButtonGroup.swift | 2 +- Sources/iOS/RadioButtonGroup.swift | 2 +- 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/Sources/iOS/BaseButtonGroup.swift b/Sources/iOS/BaseButtonGroup.swift index 8464ca54e..5ed85db06 100644 --- a/Sources/iOS/BaseButtonGroup.swift +++ b/Sources/iOS/BaseButtonGroup.swift @@ -43,11 +43,12 @@ open class BaseButtonGroup: View { open override func sizeThatFits(_ size: CGSize) -> CGSize { let size = CGSize(width: size.width == 0 ? .greatestFiniteMagnitude : size.width, height: size.height == 0 ? .greatestFiniteMagnitude : size.height) let availableW = size.width - grid.contentEdgeInsets.left - grid.contentEdgeInsets.right - grid.layoutEdgeInsets.left - grid.layoutEdgeInsets.right - var maxW: CGFloat = 0 - buttons.forEach { maxW = max(maxW, $0.sizeThatFits(.init(width: availableW, height: .greatestFiniteMagnitude)).width) } + let maxW = buttons.reduce(0) { max($0, $1.sizeThatFits(.init(width: availableW, height: .greatestFiniteMagnitude)).width) } - var h = grid.contentEdgeInsets.top + grid.contentEdgeInsets.bottom + grid.layoutEdgeInsets.top + grid.layoutEdgeInsets.bottom + CGFloat(buttons.count - 1) * grid.interimSpace - buttons.forEach { h += $0.sizeThatFits(.init(width: maxW, height: .greatestFiniteMagnitude)).height } + let h = buttons.reduce(0) { $0 + $1.sizeThatFits(.init(width: maxW, height: .greatestFiniteMagnitude)).height } + + grid.contentEdgeInsets.top + grid.contentEdgeInsets.bottom + + grid.layoutEdgeInsets.top + grid.layoutEdgeInsets.bottom + + CGFloat(buttons.count - 1) * grid.interimSpace return CGSize(width: maxW + grid.contentEdgeInsets.left + grid.contentEdgeInsets.right + grid.layoutEdgeInsets.left + grid.layoutEdgeInsets.right, height: min(h, size.height)) } diff --git a/Sources/iOS/BaseIconLayerButton.swift b/Sources/iOS/BaseIconLayerButton.swift index 528cd9a22..74a716dc5 100644 --- a/Sources/iOS/BaseIconLayerButton.swift +++ b/Sources/iOS/BaseIconLayerButton.swift @@ -12,7 +12,7 @@ import Motion /// Implements common logic for CheckButton and RadioButton open class BaseIconLayerButton: Button { class var iconLayer: BaseIconLayer { fatalError("Has to be implemented by subclasses") } - lazy var iconLayer: BaseIconLayer = { return type(of: self).iconLayer }() + lazy var iconLayer: BaseIconLayer = type(of: self).iconLayer /// A Boolean value indicating whether the button is in the selected state /// @@ -185,15 +185,15 @@ internal class BaseIconLayer: CALayer { guard !isAnimating else { return } prepareForFirstAnimation() - Motion.animate(duration: partialDuration, timingFunction: .easeInOut, animations: { + Motion.animate(duration: Constants.partialDuration, timingFunction: .easeInOut, animations: { self.isAnimating = true self.firstAnimation() }, completion: { Motion.disable { self.prepareForSecondAnimation() } - Motion.delay(self.partialDuration * self.delayFactor) { - Motion.animate(duration: self.partialDuration, timingFunction: .easeInOut, animations: { + Motion.delay(Constants.partialDuration * Constants.delayFactor) { + Motion.animate(duration: Constants.partialDuration, timingFunction: .easeInOut, animations: { self.secondAnimation() }, completion: { self.isAnimating = false }) } @@ -201,9 +201,12 @@ internal class BaseIconLayer: CALayer { } var sideLength: CGFloat { return frame.height } - let totalDuration = 0.5 - private let delayFactor = 0.33 - private var partialDuration: TimeInterval { return totalDuration / (1.0 + delayFactor + 1.0) } + + struct Constants { + static let totalDuration = 0.5 + static let delayFactor = 0.33 + static let partialDuration = totalDuration / (1.0 + delayFactor + 1.0) + } } // MARK: - Helper extension diff --git a/Sources/iOS/CheckButton.swift b/Sources/iOS/CheckButton.swift index de671636b..f13e0a5db 100644 --- a/Sources/iOS/CheckButton.swift +++ b/Sources/iOS/CheckButton.swift @@ -115,7 +115,7 @@ internal class CheckBoxLayer: BaseIconLayer { checkMarkRightLayer.strokeEnd = 0.0001 checkMarkLayer.opacity = 0 - checkMarkLayer.animate(#keyPath(CALayer.opacity), to: 1, dur: totalDuration * 0.1) + checkMarkLayer.animate(#keyPath(CALayer.opacity), to: 1, dur: Constants.totalDuration * 0.1) } else { borderLayer.borderWidth = borderLayerCenterDotBorderWidth } diff --git a/Sources/iOS/CheckButtonGroup.swift b/Sources/iOS/CheckButtonGroup.swift index 05994236e..89f3d0759 100644 --- a/Sources/iOS/CheckButtonGroup.swift +++ b/Sources/iOS/CheckButtonGroup.swift @@ -11,7 +11,7 @@ /// Unlike RadioButtonGroup, checking one check button that belongs to a check group *does not* unchecks any previously checked /// check button within the same group. Intially, all of the check buttons are unchecked. /// -/// The buttons are layout out by `Grid` system, so that changing properites of grid instance +/// The buttons are laid out by `Grid` system, so that changing properites of grid instance /// (e.g interimSpace) are reflected. open class CheckButtonGroup: BaseButtonGroup { diff --git a/Sources/iOS/RadioButtonGroup.swift b/Sources/iOS/RadioButtonGroup.swift index 008880623..ba5c74896 100644 --- a/Sources/iOS/RadioButtonGroup.swift +++ b/Sources/iOS/RadioButtonGroup.swift @@ -12,7 +12,7 @@ /// Checking one radio button that belongs to a radio group unchecks any previously checked /// radio button within the same group. Intially, all of the radio buttons are unchecked. /// -/// The buttons are layout out by `Grid` system, so that changing properites of grid instance +/// The buttons are laid out by `Grid` system, so that changing properites of grid instance /// (e.g interimSpace) are reflected. open class RadioButtonGroup: BaseButtonGroup { From 68c47fb906b9d81ed004a064e42d0117020e579c Mon Sep 17 00:00:00 2001 From: Orkhan Alikhanov Date: Sun, 21 Jan 2018 01:25:54 +0400 Subject: [PATCH 9/9] Made icon button sizing/positioning more flexible and robust --- Sources/iOS/BaseIconLayerButton.swift | 65 ++++++++++++++++++++------- 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/Sources/iOS/BaseIconLayerButton.swift b/Sources/iOS/BaseIconLayerButton.swift index 74a716dc5..5f78b41bf 100644 --- a/Sources/iOS/BaseIconLayerButton.swift +++ b/Sources/iOS/BaseIconLayerButton.swift @@ -84,13 +84,8 @@ open class BaseIconLayerButton: Button { open override func prepare() { super.prepare() layer.addSublayer(iconLayer) - - // we push the title to the right to make room for iconLayer - // `contentEdgeInsets` is used to let default implementation of - // `intrinsicContentSize` consider our titleSpacing - let titleSpacing = (margin + iconSize + margin) / 2 - titleEdgeInsets = UIEdgeInsets(top: 0, left: titleSpacing, bottom: 0, right: -titleSpacing) - contentEdgeInsets = UIEdgeInsets(top: margin, left: titleSpacing, bottom: margin, right: titleSpacing) + contentHorizontalAlignment = .left // default was .center + reloadImage() } open override func touchesBegan(_ touches: Set, with event: UIEvent?) { @@ -108,22 +103,62 @@ open class BaseIconLayerButton: Button { super.layoutSubviews() // positioning iconLayer + let insets = iconEdgeInsets iconLayer.frame.size = CGSize(width: iconSize, height: iconSize) - iconLayer.frame.origin.y = bounds.height / 2 - iconSize / 2 - iconLayer.frame.origin.x = margin - + iconLayer.frame.origin = CGPoint(x: imageView!.frame.minX + insets.left, y: imageView!.frame.minY + insets.top) // visualLayer is the layer where pulse layer is expanding. // So we position it at the center of iconLayer, and make it // small circle, so that the expansion of pulse layer is clipped off - let s = margin + iconSize + margin // considering margin as well - visualLayer.bounds.size = CGSize(width: s, height: s) + let w = iconSize + insets.left + insets.right + let h = iconSize + insets.top + insets.bottom + let pulseSize = min(w, h) + visualLayer.bounds.size = CGSize(width: pulseSize, height: pulseSize) visualLayer.frame.center = iconLayer.frame.center - visualLayer.cornerRadius = s / 2 + visualLayer.cornerRadius = pulseSize / 2 + } + + /// Size of the icon + /// + /// This property affects `intrinsicContentSize` and `sizeThatFits(_:)` + /// Use `iconEdgeInsets` to set margins. + open var iconSize: CGFloat = 16 { + didSet { + reloadImage() + } } - private let margin: CGFloat = 5 - private let iconSize: CGFloat = 16 + /// The *outset* margins for the rectangle around the button’s icon. + /// + /// You can specify a different value for each of the four margins (top, left, bottom, right) + /// This property affects `intrinsicContentSize` and `sizeThatFits(_:)` and position of the icon + /// within the rectangle. + /// + /// You can use `iconSize` and this property, or `titleEdgeInsets` and `contentEdgeInsets` to position + /// the icon however you want. + /// For negative values, behavior is undefined. Default is `5.0` for all four margins + open var iconEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) { + didSet { + reloadImage() + } + } + + + /// This might be considered as a hackish way, but it's just manipulation + /// UIButton considers size of the `currentImage` to determine `intrinsicContentSize` + /// and `sizeThatFits(_:)`, and to position `titleLabel`. + /// So, we make use of this property (by setting transparent image) to make room for our icon + /// without making much effort (like playing with `titleEdgeInsets` and `contentEdgeInsets`) + /// Size of the image equals to `iconSize` plus corresponsing `iconEdgeInsets` values + private func reloadImage() { + let insets = iconEdgeInsets + let w = iconSize + insets.left + insets.right + let h = iconSize + insets.top + insets.bottom + UIGraphicsBeginImageContext(CGSize(width: w, height: h)) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + self.image = image + } } // MARK: - BaseIconLayer