From d2df72619d75317d667f767946388fdfa3a362f6 Mon Sep 17 00:00:00 2001 From: Florentin Bekier Date: Sun, 28 Feb 2021 14:18:22 +0100 Subject: [PATCH] Initial commit --- .gitignore | 5 + LICENSE | 21 ++ Package.swift | 28 ++ README.md | 38 ++ .../ColorModel.swift | 61 +++ .../MaterialOutlinedTextField.swift | 351 ++++++++++++++++++ ...BezierPath+MaterialOutlinedTextField.swift | 39 ++ Tests/LinuxMain.swift | 7 + .../MaterialOutlinedTextFieldTests.swift | 15 + .../XCTestManifests.swift | 9 + 10 files changed, 574 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/MaterialOutlinedTextField/ColorModel.swift create mode 100644 Sources/MaterialOutlinedTextField/MaterialOutlinedTextField.swift create mode 100644 Sources/MaterialOutlinedTextField/UIBezierPath+MaterialOutlinedTextField.swift create mode 100644 Tests/LinuxMain.swift create mode 100644 Tests/MaterialOutlinedTextFieldTests/MaterialOutlinedTextFieldTests.swift create mode 100644 Tests/MaterialOutlinedTextFieldTests/XCTestManifests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95c4320 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..95197c9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Florentin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..b43ff70 --- /dev/null +++ b/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version:5.3 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MaterialOutlinedTextField", + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "MaterialOutlinedTextField", + targets: ["MaterialOutlinedTextField"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "MaterialOutlinedTextField", + dependencies: []), + .testTarget( + name: "MaterialOutlinedTextFieldTests", + dependencies: ["MaterialOutlinedTextField"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ae5d26 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# MaterialOutlinedTextField + +A simple [Material Design outlined text field](https://material.io/components/text-fields) implementation in Swift. + +## Installation + +Installation can be done using Swift Package Manager. In Xcode, go to File > Swift Packages > Add Package Dependency… and paste the repository URL (https://github.com/flowbe/MaterialOutlinedTextField) to add it. + +You can also add the dependency directly in your `Package.swift` file: +```swift +dependencies: [ + .package(url: "https://github.com/flowbe/MaterialOutlinedTextField.git", .upToNextMajor(from: "0.1.0")) +] +``` + +## Usage + +`MaterialOutlinedTextField` has the same interface as `UITextField` with a few extra properties and methods: + +- `label`: The label appearing as floating or placeholder. You can use this property to set his text content. +- `labelBehavior`: Defines the behavior of the label when the text field is editing. The possible values are `floats` (default) or `disappears`. +- `containerRadius`: The corner radius of the text field. +- `colorModel`: The current color model based on the current state (get-only). +- `outlineLineWidth`: The current outline line width model based on the current state (get-only). +- `setColorModel(_ colorModel: ColorModel, for state: State)`: Set the color model for the specified state. +- `setOutlineLineWidth(_ outlineLineWidth: CGFloat, for state: State)`: Set the color model for the specified state. + +## Example + +```swift +let t = MaterialOutlinedTextField(frame: CGRect(x: 0, y: 0, width: 200, height: 56)) +textField.label.text = "Label" +textField.placeholder = "Placeholder" +textField.clearButtonMode = .whileEditing +textField.setColorModel(ColorModel(textColor: .gray, floatingLabelColor: .gray, normalLabelColor: .gray, outlineColor: .gray), for: .normal) +textField.setColorModel(ColorModel(textColor: .systemBlue, floatingLabelColor: .systemBlue, normalLabelColor: .systemBlue, outlineColor: .systemBlue), for: .editing) +textField.setColorModel(ColorModel(with: .disabled), for: .disabled) +``` diff --git a/Sources/MaterialOutlinedTextField/ColorModel.swift b/Sources/MaterialOutlinedTextField/ColorModel.swift new file mode 100644 index 0000000..5a8a170 --- /dev/null +++ b/Sources/MaterialOutlinedTextField/ColorModel.swift @@ -0,0 +1,61 @@ +// +// ColorModel.swift +// MaterialOutlinedTextField +// +// Created by Florentin on 28/02/2021. +// Copyright © 2021 Florentin. All rights reserved. +// + +import UIKit + +public struct ColorModel { + /// Text color. + public var textColor: UIColor + /// Floating label color. + public var floatingLabelColor: UIColor + /// Normal label color. + public var normalLabelColor: UIColor + /// Outline line color. + public var outlineColor: UIColor + + public init(with state: MaterialOutlinedTextField.State) { + var textColor = UIColor.black + var floatingLabelColor = UIColor.black + var normalLabelColor = UIColor.darkGray + var outlineColor = UIColor.black + + if #available(iOS 13.0, *) { + textColor = .label + floatingLabelColor = .label + normalLabelColor = .label + outlineColor = .label + } + + let disabledAlpha: CGFloat = 0.6 + + if state == .disabled { + textColor = textColor.withAlphaComponent(disabledAlpha) + floatingLabelColor = floatingLabelColor.withAlphaComponent(disabledAlpha) + normalLabelColor = normalLabelColor.withAlphaComponent(disabledAlpha) + outlineColor = normalLabelColor.withAlphaComponent(disabledAlpha) + } + + self.init(textColor: textColor, + floatingLabelColor: floatingLabelColor, + normalLabelColor: normalLabelColor, + outlineColor: outlineColor) + } + + /// Creates a new color model. + /// - Parameters: + /// - textColor: Text color + /// - floatingLabelColor: Floating label color + /// - normalLabelColor: Normal label color + /// - outlineColor: Outline line color + public init(textColor: UIColor, floatingLabelColor: UIColor, normalLabelColor: UIColor, outlineColor: UIColor) { + self.textColor = textColor + self.floatingLabelColor = floatingLabelColor + self.normalLabelColor = normalLabelColor + self.outlineColor = outlineColor + } +} diff --git a/Sources/MaterialOutlinedTextField/MaterialOutlinedTextField.swift b/Sources/MaterialOutlinedTextField/MaterialOutlinedTextField.swift new file mode 100644 index 0000000..680af66 --- /dev/null +++ b/Sources/MaterialOutlinedTextField/MaterialOutlinedTextField.swift @@ -0,0 +1,351 @@ +// +// MaterialOutlinedTextField.swift +// MaterialOutlinedTextField +// +// Created by Florentin on 28/02/2021. +// Copyright © 2021 Florentin. All rights reserved. +// + +import UIKit + +public class MaterialOutlinedTextField: UITextField { + + // MARK: - Enums + + /// Possible behaviors for the label + public enum LabelBehavior { + case floats, disappears + } + + private enum LabelPosition { + case none, floating, normal + + static func with(hasLabelText: Bool, hasText: Bool, canLabelFloat: Bool, isEditing: Bool) -> LabelPosition { + if hasLabelText { + if isEditing || hasText { + return canLabelFloat ? .floating : .none + } else { + return .normal + } + } + return .none + } + } + + /// Possible states for the text field + public enum State { + case normal, editing, disabled + + static func with(isEnabled: Bool, isEditing: Bool) -> State { + if isEnabled { + return isEditing ? .editing : .normal + } + return .disabled + } + } + + // MARK: - Properties + + /// The floating label. + public var label = UILabel() + /// Defines the behavior of the label when the text field is editing. The possible values are `floats` (default) or `disappears`. + public var labelBehavior = LabelBehavior.floats + /// The corner radius of the text field. + public var containerRadius: CGFloat = 4 + /// The current color model based on the current state. + public var colorModel: ColorModel { + return colorModels[textControlState] ?? ColorModel(with: textControlState) + } + /// The current outline line width based on the current state. + public var outlineLineWidth: CGFloat { + return outlineLineWidths[textControlState] ?? 1.0 + } + + private var textControlState: State { + State.with(isEnabled: isEnabled, isEditing: isEditing) + } + private var labelPosition: LabelPosition { + LabelPosition.with(hasLabelText: !(label.text?.isEmpty ?? true), + hasText: !(text?.isEmpty ?? true), + canLabelFloat: labelBehavior == .floats, + isEditing: isEditing) + } + private var colorModels: [State: ColorModel] = [ + .normal: ColorModel(with: .normal), + .editing: ColorModel(with: .editing), + .disabled: ColorModel(with: .disabled) + ] + private var outlineLineWidths: [State: CGFloat] = [ + .normal: 1.0, + .editing: 2.0, + .disabled: 1.0 + ] + private var shouldPlaceholderBeVisible: Bool { + let hasPlaceholder = !(placeholder?.isEmpty ?? true) + let hasText = !(text?.isEmpty ?? true) + return hasPlaceholder && !hasText && labelPosition != .normal + } + private var normalFont: UIFont { + font ?? UIFont.preferredFont(forTextStyle: .body) + } + private var floatingFont: UIFont { + let font = normalFont + return font.withSize(font.pointSize * 0.8) + } + + private var outlineSublayer = CAShapeLayer() + private var labelFrame = CGRect.zero + + // MARK: - Constants + + private let animationDuration: TimeInterval = 0.15 + private let leftPadding: CGFloat = 16 + private let rightPadding: CGFloat = 12 + private let clearButtonSideLength: CGFloat = 24 + private let floatingLabelOutlineSidePadding: CGFloat = 4 + + // MARK: - Object lifecycle + + override init(frame: CGRect) { + super.init(frame: frame) + initTextField() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + initTextField() + } + + private func initTextField() { + setUpLabel() + setUpOutlineSublayer() + } + + // MARK: - View setup + + private func setUpLabel() { + addSubview(label) + } + + private func setUpOutlineSublayer() { + outlineSublayer.fillColor = UIColor.clear.cgColor + outlineSublayer.lineWidth = outlineLineWidth + } + + // MARK: - Accessors + + /// Set the color model for the specified state. + /// - Parameters: + /// - colorModel: Color model + /// - state: State + public func setColorModel(_ colorModel: ColorModel, for state: State) { + colorModels[state] = colorModel + setNeedsLayout() + } + + /// Set the outline line width for the specified state. + /// - Parameters: + /// - outlineLineWidth: Outline line width + /// - state: State + public func setOutlineLineWidth(_ outlineLineWidth: CGFloat, for state: State) { + outlineLineWidths[state] = outlineLineWidth + setNeedsLayout() + } + + // MARK: - UIView overrides + + public override func layoutSubviews() { + preLayoutSubviews() + super.layoutSubviews() + postLayoutSubviews() + } + + public override var intrinsicContentSize: CGSize { + return CGSize(width: bounds.width, height: 56) + } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + setNeedsLayout() + } + + // MARK: - Layout + + private func preLayoutSubviews() { + applyColors() + switch labelPosition { + case .none: + self.labelFrame = .zero + case .normal: + self.labelFrame = labelFrameNormal + case .floating: + self.labelFrame = labelFrameFloating + } + } + + private func postLayoutSubviews() { + label.isHidden = labelPosition == .none + animateLabel() + applyStyle() + } + + // MARK: - UITextField Layout Overrides + + public override func textRect(forBounds bounds: CGRect) -> CGRect { + let superRect = super.textRect(forBounds: bounds) + return CGRect(x: leftPadding, y: superRect.origin.y, width: superRect.width - leftPadding * 1.5, height: superRect.height) + } + + public override func editingRect(forBounds bounds: CGRect) -> CGRect { + let superRect = super.editingRect(forBounds: bounds) + return CGRect(x: leftPadding, y: superRect.origin.y, width: superRect.width - leftPadding * 1.5, height: superRect.height) + } + + public override func borderRect(forBounds bounds: CGRect) -> CGRect { + return .zero + } + + public override func placeholderRect(forBounds bounds: CGRect) -> CGRect { + if shouldPlaceholderBeVisible { + return super.placeholderRect(forBounds: bounds) + } + return .zero + } + + public override func clearButtonRect(forBounds bounds: CGRect) -> CGRect { + return CGRect(x: frame.width - clearButtonSideLength - rightPadding, y: (frame.height - clearButtonSideLength) / 2, width: clearButtonSideLength, height: clearButtonSideLength) + } + + // MARK: - UITextField drawing overrides + + public override func drawPlaceholder(in rect: CGRect) { + if shouldPlaceholderBeVisible { + super.drawPlaceholder(in: rect) + } + } + + // MARK: - Label + + private var labelFrameNormal: CGRect { + let rect = textRect(forBounds: bounds) + let labelMinX = rect.minX + let labelMaxX = rect.maxX + let maxWidth = labelMaxX - labelMinX + let size = floatingLabelSize(with: label.text ?? "", maxWidth: maxWidth, font: normalFont) + let originX = labelMinX + let originY = rect.midY - (0.5 * size.height) + return CGRect(x: originX, y: originY, width: size.width, height: size.height) + } + + private var labelFrameFloating: CGRect { + let rect = textRect(forBounds: bounds) + let labelMinX = rect.minX + let labelMaxX = rect.maxX + let maxWidth = labelMaxX - labelMinX + let size = floatingLabelSize(with: label.text ?? "", maxWidth: maxWidth, font: floatingFont) + let originX = labelMinX + let originY = 0 - (0.5 * floatingFont.lineHeight) + return CGRect(x: originX, y: originY, width: size.width, height: size.height) + } + + private func floatingLabelSize(with placeholder: String, maxWidth: CGFloat, font: UIFont) -> CGSize { + let fittingSize = CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude) + var rect = (placeholder as NSString).boundingRect(with: fittingSize, options: .usesLineFragmentOrigin, attributes: [.font: font], context: nil) + rect.size.height = font.lineHeight + return rect.size + } + + private func animateLabel() { + // TODO: Improve animations + let animations = { + switch self.labelPosition { + case .floating: + self.label.alpha = 1 + self.label.font = self.floatingFont + case .normal: + self.label.alpha = 1 + self.label.font = self.normalFont + case .none: + self.label.alpha = 0 + } + self.label.frame = self.labelFrame + } + let shouldPerformAnimation = !label.frame.equalTo(.zero) + + if shouldPerformAnimation { + UIView.animate(withDuration: animationDuration, animations: animations) + } else { + animations() + } + } + + // MARK: - Coloring and style + + private func applyColors() { + let labelColor: UIColor + switch labelPosition { + case .none: + labelColor = .clear + case .normal: + labelColor = colorModel.normalLabelColor + case .floating: + labelColor = colorModel.floatingLabelColor + } + label.textColor = labelColor + textColor = colorModel.textColor + } + + private func applyStyle() { + let path = outlinePath(with: bounds, labelFrame: labelFrame, containerHeight: bounds.height, lineWidth: outlineLineWidth, cornerRadius: containerRadius, isLabelFloating: labelPosition == .floating) + CATransaction.begin() + CATransaction.setDisableActions(true) + outlineSublayer.path = path.cgPath + outlineSublayer.lineWidth = outlineLineWidth + CATransaction.commit() + outlineSublayer.strokeColor = colorModel.outlineColor.cgColor + + if outlineSublayer.superlayer != layer { + layer.insertSublayer(outlineSublayer, at: 0) + } + } + + private func outlinePath(with viewBounds: CGRect, labelFrame: CGRect, containerHeight: CGFloat, lineWidth: CGFloat, cornerRadius: CGFloat, isLabelFloating: Bool) -> UIBezierPath { + let path = UIBezierPath() + let textFieldWidth = viewBounds.width + let sublayerMinY = CGFloat.zero + let sublayerMaxY = containerHeight + + let startingPoint = CGPoint(x: cornerRadius, y: sublayerMinY) + let topRightCornerPoint1 = CGPoint(x: textFieldWidth - cornerRadius, y: sublayerMinY) + path.move(to: startingPoint) + if isLabelFloating { + let leftLineBreak = labelFrame.minX - floatingLabelOutlineSidePadding + let rightLineBreak = labelFrame.maxX + floatingLabelOutlineSidePadding + path.addLine(to: CGPoint(x: leftLineBreak, y: sublayerMinY)) + path.move(to: CGPoint(x: rightLineBreak, y: sublayerMinY)) + path.addLine(to: CGPoint(x: rightLineBreak, y: sublayerMinY)) + } else { + path.addLine(to: topRightCornerPoint1) + } + + let topRightCornerPoint2 = CGPoint(x: textFieldWidth, y: sublayerMinY + cornerRadius) + path.addTopRightCorner(from: topRightCornerPoint1, to: topRightCornerPoint2, with: cornerRadius) + + let bottomRightCornerPoint1 = CGPoint(x: textFieldWidth, y: sublayerMaxY - cornerRadius) + let bottomRightCornerPoint2 = CGPoint(x: textFieldWidth - cornerRadius, y: sublayerMaxY) + path.addLine(to: bottomRightCornerPoint1) + path.addBottomRightCorner(from: bottomRightCornerPoint1, to: bottomRightCornerPoint2, with: cornerRadius) + + let bottomLeftCornerPoint1 = CGPoint(x: cornerRadius, y: sublayerMaxY) + let bottomLeftCornerPoint2 = CGPoint(x: 0, y: sublayerMaxY - cornerRadius) + path.addLine(to: bottomLeftCornerPoint1) + path.addBottomLeftCorner(from: bottomLeftCornerPoint1, to: bottomLeftCornerPoint2, with: cornerRadius) + + let topLeftCornerPoint1 = CGPoint(x: 0, y: sublayerMinY + cornerRadius) + let topLeftCornerPoint2 = CGPoint(x: cornerRadius, y: sublayerMinY) + path.addLine(to: topLeftCornerPoint1) + path.addTopLeftCorner(from: topLeftCornerPoint1, to: topLeftCornerPoint2, with: cornerRadius) + + return path + } +} diff --git a/Sources/MaterialOutlinedTextField/UIBezierPath+MaterialOutlinedTextField.swift b/Sources/MaterialOutlinedTextField/UIBezierPath+MaterialOutlinedTextField.swift new file mode 100644 index 0000000..1ce7ba5 --- /dev/null +++ b/Sources/MaterialOutlinedTextField/UIBezierPath+MaterialOutlinedTextField.swift @@ -0,0 +1,39 @@ +// +// UIBezierPath+MaterialOutlinedTextField.swift +// MaterialOutlinedTextField +// +// Created by Florentin on 28/02/2021. +// Copyright © 2021 Florentin. All rights reserved. +// + +import UIKit + +extension UIBezierPath { + func addTopRightCorner(from point1: CGPoint, to point2: CGPoint, with radius: CGFloat) { + let startAngle = -(CGFloat.pi / 2) + let endAngle = CGFloat.zero + let center = CGPoint(x: point1.x, y: point2.y) + addArc(withCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true) + } + + func addBottomRightCorner(from point1: CGPoint, to point2: CGPoint, with radius: CGFloat) { + let startAngle = CGFloat.zero + let endAngle = -((CGFloat.pi * 3) / 2) + let center = CGPoint(x: point2.x, y: point1.y) + addArc(withCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true) + } + + func addBottomLeftCorner(from point1: CGPoint, to point2: CGPoint, with radius: CGFloat) { + let startAngle = -((CGFloat.pi * 3) / 2) + let endAngle = -CGFloat.pi + let center = CGPoint(x: point1.x, y: point2.y) + addArc(withCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true) + } + + func addTopLeftCorner(from point1: CGPoint, to point2: CGPoint, with radius: CGFloat) { + let startAngle = -CGFloat.pi + let endAngle = -(CGFloat.pi / 2) + let center = CGPoint(x: point1.x + radius, y: point2.y + radius) + addArc(withCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true) + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift new file mode 100644 index 0000000..77cb1b4 --- /dev/null +++ b/Tests/LinuxMain.swift @@ -0,0 +1,7 @@ +import XCTest + +import MaterialOutlinedTextFieldTests + +var tests = [XCTestCaseEntry]() +tests += MaterialOutlinedTextFieldTests.allTests() +XCTMain(tests) diff --git a/Tests/MaterialOutlinedTextFieldTests/MaterialOutlinedTextFieldTests.swift b/Tests/MaterialOutlinedTextFieldTests/MaterialOutlinedTextFieldTests.swift new file mode 100644 index 0000000..bd5071c --- /dev/null +++ b/Tests/MaterialOutlinedTextFieldTests/MaterialOutlinedTextFieldTests.swift @@ -0,0 +1,15 @@ +import XCTest +@testable import MaterialOutlinedTextField + +final class MaterialOutlinedTextFieldTests: XCTestCase { + func testExample() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct + // results. + XCTAssertEqual(MaterialOutlinedTextField().text, "Hello, World!") + } + + static var allTests = [ + ("testExample", testExample), + ] +} diff --git a/Tests/MaterialOutlinedTextFieldTests/XCTestManifests.swift b/Tests/MaterialOutlinedTextFieldTests/XCTestManifests.swift new file mode 100644 index 0000000..c7cc30c --- /dev/null +++ b/Tests/MaterialOutlinedTextFieldTests/XCTestManifests.swift @@ -0,0 +1,9 @@ +import XCTest + +#if !canImport(ObjectiveC) +public func allTests() -> [XCTestCaseEntry] { + return [ + testCase(MaterialOutlinedTextFieldTests.allTests), + ] +} +#endif