├── toolbar.png
├── Tests
└── TextEditorTests
│ └── TextEditorTests.swift
├── Sources
└── TextEditor
│ ├── Configuration.swift
│ ├── TextEditorDelegate.swift
│ ├── Views
│ ├── ActionButton.swift
│ └── ScrollableToolbar.swift
│ ├── RichTextEditor.swift
│ ├── Extensions.swift
│ ├── InputAccessoryView.swift
│ └── TextEditorWrapper.swift
├── LICENSE
├── Package.swift
├── README.md
└── .gitignore
/toolbar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nothingsh/TextEditor/HEAD/toolbar.png
--------------------------------------------------------------------------------
/Tests/TextEditorTests/TextEditorTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import TextEditor
3 |
4 | final class TextEditorTests: XCTestCase {
5 | func testExample() throws {
6 | // This is an example of a functional test case.
7 | // Use XCTAssert and related functions to verify your tests produce the correct
8 | // results.
9 | // XCTAssertEqual(TextEditor().text, "Hello, World!")
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Sources/TextEditor/Configuration.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Steven Zhang on 3/15/22.
6 | //
7 |
8 | import SwiftUI
9 |
10 | public enum EditorSection: CaseIterable {
11 | /// include bold, italic, underline, strike through font effects
12 | case textStyle
13 | /// include increase and decreas font size
14 | case fontAdjustment
15 | /// text alignment
16 | case textAlignment
17 | /// insert image
18 | case image
19 | /// text color p
20 | case colorPalette
21 | }
22 |
--------------------------------------------------------------------------------
/Sources/TextEditor/TextEditorDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Steven Zhang on 3/12/22.
6 | //
7 |
8 | import SwiftUI
9 |
10 | protocol TextEditorDelegate: AnyObject {
11 | // Font
12 | func textBold()
13 | func textItalic()
14 | func textStrike()
15 | func textUnderline()
16 | func adjustFontSize(isIncrease: Bool)
17 | func textColor(color: UIColor)
18 | func textFont(name: String)
19 | func insertImage()
20 | func textAlign(align: NSTextAlignment)
21 | func hideKeyboard()
22 | }
23 |
24 | extension NSTextAlignment {
25 | var imageName: String {
26 | switch self {
27 | case .left: return "text.alignleft"
28 | case .center: return "text.aligncenter"
29 | case .right: return "text.alignright"
30 | case .justified: return "text.justify"
31 | case .natural: return "text.aligncenter"
32 | @unknown default: return "text.aligncenter"
33 | }
34 | }
35 |
36 | static let available: [NSTextAlignment] = [.left, .right, .center]
37 | }
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Steven Zhang
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.5
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "TextEditor",
8 | platforms: [.iOS(.v13)],
9 | products: [
10 | // Products define the executables and libraries a package produces, and make them visible to other packages.
11 | .library(
12 | name: "TextEditor",
13 | targets: ["TextEditor"]),
14 | ],
15 | dependencies: [
16 | // Dependencies declare other packages that this package depends on.
17 | // .package(url: /* package url */, from: "1.0.0"),
18 | ],
19 | targets: [
20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
21 | // Targets can depend on other targets in this package, and on products in packages this package depends on.
22 | .target(
23 | name: "TextEditor",
24 | dependencies: []),
25 | .testTarget(
26 | name: "TextEditorTests",
27 | dependencies: ["TextEditor"]),
28 | ],
29 | swiftLanguageVersions: [.v5]
30 | )
31 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # TextEditor
2 |
3 |
4 |
5 | ## Preview
6 |
7 | ```swift
8 | import TextEditor
9 |
10 | struct ContentView: View {
11 | let richText = NSMutableAttributedString()
12 |
13 | var body: some View {
14 | ZStack {
15 | Color(hex: "97DBAE")
16 | .edgesIgnoringSafeArea(.all)
17 | RichTextEditor(richText: richText) { _ in
18 | // try to save edited rich text here
19 | }
20 | .padding(10)
21 | .background(
22 | Rectangle()
23 | .stroke(lineWidth: 1)
24 | )
25 | .padding()
26 | }
27 | }
28 | }
29 | ```
30 |
31 |
32 |
33 |
34 | ## Usage
35 |
36 | Add the following lines to your `Package.swift` or use Xcode "Add Package Dependency" menu.
37 |
38 | ```swift
39 | .package(name: "TextEditor", url: "https://github.com/nothingsh/TextEditor", ...)
40 | ```
41 |
42 | ## Todo
43 |
44 | - [ ] Add font selection
45 | - [ ] Make Input Accessory View Configurable
46 |
--------------------------------------------------------------------------------
/Sources/TextEditor/Views/ActionButton.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ActionButton.swift
3 | //
4 | //
5 | // Created by Wynn Zhang on 10/7/23.
6 | //
7 |
8 | import UIKit
9 |
10 | class ActionButton: UIButton {
11 | private static let SYMBOL_SIZE: CGFloat = 32
12 | private let size: CGFloat
13 | var symbolName: String
14 |
15 | init(systemName: String, size: CGFloat = ActionButton.SYMBOL_SIZE) {
16 | self.size = size
17 | self.symbolName = systemName
18 | super.init(frame: CGRect(origin: .zero, size: .zero))
19 |
20 | self.backgroundColor = .clear
21 | self.setImage(systemName: systemName)
22 | }
23 |
24 | func setImage(systemName: String) {
25 | guard let image = UIImage(systemName: systemName, withConfiguration: symbolConfiguration) else {
26 | return
27 | }
28 |
29 | guard image.size.height != 0 else {
30 | return
31 | }
32 |
33 | self.setImage(image, for: .normal)
34 |
35 | let ratio = image.size.width / image.size.height
36 | let aspectRatio = (ratio > 1.1) ? ratio : 1
37 | let adjustedWidth = self.size * aspectRatio
38 | self.widthAnchor.constraint(equalToConstant: adjustedWidth).isActive = true
39 | self.heightAnchor.constraint(equalToConstant: self.size).isActive = true
40 | }
41 |
42 | required init?(coder: NSCoder) {
43 | fatalError("init(coder:) has not been implemented")
44 | }
45 |
46 | var symbolConfiguration: UIImage.SymbolConfiguration {
47 | UIImage.SymbolConfiguration(pointSize: size * 0.85, weight: .light, scale: .small)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Sources/TextEditor/Views/ScrollableToolbar.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ScrollableToolbar.swift
3 | //
4 | //
5 | // Created by Wynn Zhang on 10/8/23.
6 | //
7 |
8 | import UIKit
9 |
10 | class ScrollableToolbar: UIScrollView {
11 | var itemStackView: UIStackView
12 |
13 | init(items: [UIView]) {
14 | self.itemStackView = UIStackView(arrangedSubviews: items)
15 | super.init(frame: .zero)
16 |
17 | self.itemStackView.axis = .horizontal
18 | self.itemStackView.alignment = .center
19 | self.itemStackView.distribution = .equalSpacing
20 | self.itemStackView.backgroundColor = .clear
21 | self.itemStackView.spacing = 4
22 | self.setupToolbar()
23 | }
24 |
25 | required init?(coder: NSCoder) {
26 | fatalError("init(coder:) has not been implemented")
27 | }
28 |
29 | private func setupToolbar() {
30 | self.addSubview(self.itemStackView)
31 | self.itemStackView.translatesAutoresizingMaskIntoConstraints = false
32 | self.showsVerticalScrollIndicator = false
33 | self.showsHorizontalScrollIndicator = false
34 | self.isScrollEnabled = true
35 |
36 | NSLayoutConstraint.activate([
37 | self.itemStackView.leadingAnchor.constraint(equalTo: self.contentLayoutGuide.leadingAnchor, constant: 8),
38 | self.itemStackView.trailingAnchor.constraint(equalTo: self.contentLayoutGuide.trailingAnchor),
39 | self.itemStackView.topAnchor.constraint(equalTo: self.contentLayoutGuide.topAnchor),
40 | self.itemStackView.bottomAnchor.constraint(equalTo: self.contentLayoutGuide.bottomAnchor),
41 | self.itemStackView.heightAnchor.constraint(equalTo: self.frameLayoutGuide.heightAnchor),
42 | ])
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Sources/TextEditor/RichTextEditor.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @available(iOS 13.0, *)
4 | public struct RichTextEditor: View {
5 | @State var dynamicHeight: CGFloat = 100
6 |
7 | private let richText: NSMutableAttributedString
8 | private let placeholder: String
9 | private let accessorySections: Array
10 | private let onCommit: (NSAttributedString) -> Void
11 |
12 | public init(
13 | richText: NSMutableAttributedString,
14 | placeholder: String = "Type ...",
15 | accessory sections: Array = EditorSection.allCases,
16 | onCommit: @escaping ((NSAttributedString) -> Void)
17 | ) {
18 | self.richText = richText
19 | self.placeholder = placeholder
20 | self.accessorySections = sections
21 | self.onCommit = onCommit
22 | }
23 |
24 | public var body: some View {
25 | TextEditorWrapper(
26 | richText: richText,
27 | height: $dynamicHeight,
28 | placeholder: placeholder,
29 | sections: accessorySections,
30 | onCommit: onCommit
31 | )
32 | .frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
33 | }
34 | }
35 |
36 | @available(iOS 13.0, *)
37 | struct RichTextEditor_Previews: PreviewProvider {
38 | static var previews: some View {
39 | Preview()
40 | }
41 |
42 | struct Preview: View {
43 | let richText: NSMutableAttributedString = NSMutableAttributedString()
44 | @State var text = NSAttributedString(string: "Hello")
45 |
46 | var body: some View {
47 | ZStack {
48 | Color(hex: "EED6C4")
49 | .edgesIgnoringSafeArea(.all)
50 | VStack {
51 | RichTextEditor(richText: richText) { attributedString in
52 | self.text = attributedString
53 | }
54 | .padding()
55 | .background(
56 | Rectangle().stroke(lineWidth: 1)
57 | )
58 | .padding()
59 | Text(text.string)
60 | }
61 | }
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | # Package.resolved
43 | # *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | # .swiftpm
48 |
49 | .build/
50 |
51 | # CocoaPods
52 | #
53 | # We recommend against adding the Pods directory to your .gitignore. However
54 | # you should judge for yourself, the pros and cons are mentioned at:
55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56 | #
57 | # Pods/
58 | #
59 | # Add this line if you want to avoid checking in source code from the Xcode workspace
60 | # *.xcworkspace
61 |
62 | # Carthage
63 | #
64 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
65 | # Carthage/Checkouts
66 |
67 | Carthage/Build/
68 |
69 | # Accio dependency management
70 | Dependencies/
71 | .accio/
72 |
73 | # fastlane
74 | #
75 | # It is recommended to not store the screenshots in the git repo.
76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
77 | # For more information about the recommended setup visit:
78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
79 |
80 | fastlane/report.xml
81 | fastlane/Preview.html
82 | fastlane/screenshots/**/*.png
83 | fastlane/test_output
84 |
85 | # Code Injection
86 | #
87 | # After new code Injection tools there's a generated folder /iOSInjectionProject
88 | # https://github.com/johnno1962/injectionforxcode
89 |
90 | iOSInjectionProject/
91 |
--------------------------------------------------------------------------------
/Sources/TextEditor/Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Steven Zhang on 3/12/22.
6 | //
7 |
8 | import SwiftUI
9 |
10 | @available(iOS 13.0, *)
11 | public extension Color {
12 | init(hex: String, alpha: Double = 1) {
13 | let scanner = Scanner(string: hex)
14 | var rgbValue: UInt64 = 0
15 | scanner.scanHexInt64(&rgbValue)
16 |
17 | let r = (rgbValue & 0xff0000) >> 16
18 | let g = (rgbValue & 0xff00) >> 8
19 | let b = (rgbValue & 0xff)
20 |
21 | self.init(red: Double(r)/0xff, green: Double(g)/0xff, blue: Double(b)/0xff, opacity: alpha)
22 | }
23 | }
24 |
25 | public extension UIColor {
26 | convenience init(hex: String, alpha: Double = 1) {
27 | let scanner = Scanner(string: hex)
28 | var rgbValue: UInt64 = 0
29 | scanner.scanHexInt64(&rgbValue)
30 |
31 | let r = (rgbValue & 0xff0000) >> 16
32 | let g = (rgbValue & 0xff00) >> 8
33 | let b = (rgbValue & 0xff)
34 |
35 | self.init(red: Double(r)/0xff, green: Double(g)/0xff, blue: Double(b)/0xff, alpha: alpha)
36 | }
37 | }
38 |
39 | class ColorLibrary {
40 | static private let textColorHeses: [String] = ["DD4E48", "ED734A", "F1AA3E", "479D60", "5AC2C5", "50AAF8", "2355F6", "9123F4", "EA5CAE"]
41 | static let textColors: [UIColor] = [UIColor.label] + textColorHeses.map({ UIColor(hex: $0) })
42 | }
43 |
44 | extension UIImage {
45 | func roundedImageWithBorder(color: UIColor) -> UIImage? {
46 | let length = min(size.width, size.height)
47 | let borderWidth = length * 0.04
48 | let cornerRadius = length * 0.01
49 |
50 | let rect = CGSize(width: size.width+borderWidth*1.5, height:size.height+borderWidth*1.8)
51 | let imageView = UIImageView(frame: CGRect(origin: CGPoint(x: 0, y: 0), size: rect))
52 | imageView.backgroundColor = color
53 | imageView.contentMode = .center
54 | imageView.image = self
55 | imageView.layer.cornerRadius = cornerRadius
56 | imageView.layer.masksToBounds = true
57 | imageView.layer.borderWidth = borderWidth
58 | imageView.layer.borderColor = color.cgColor
59 | UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, false, scale)
60 | guard let context = UIGraphicsGetCurrentContext() else { return nil }
61 | imageView.layer.render(in: context)
62 | let result = UIGraphicsGetImageFromCurrentImageContext()
63 | UIGraphicsEndImageContext()
64 | return result
65 | }
66 | }
67 |
68 | extension NSRange {
69 | var isEmpty: Bool {
70 | return self.upperBound == self.lowerBound
71 | }
72 | }
73 |
74 | extension CGSize {
75 | /// min value of width and height
76 | var minLength: CGFloat {
77 | return min(self.width, self.height)
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/Sources/TextEditor/InputAccessoryView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Steven Zhang on 3/12/22.
6 | //
7 |
8 | import SwiftUI
9 |
10 | @available(iOS 13.0, *)
11 | final class InputAccessoryView: UIStackView {
12 | private var accessorySections: Array
13 | private var textFontName: String = "AvenirNext-Regular"
14 |
15 | private let edgePadding: CGFloat = 5
16 | private let cornerRadius: CGFloat = 4
17 | private let selectedColor = UIColor.separator
18 | private let containerBackgroundColor: UIColor = .systemBackground
19 |
20 | weak var delegate: TextEditorDelegate!
21 |
22 | // MARK: Input Accessory
23 |
24 | /// buttons include bold, italic, underline, strike through effects
25 | private lazy var textStyleItems: [UIView] = {
26 | var systemNames = ["bold", "italic", "underline", "strikethrough"]
27 |
28 | return systemNames.enumerated().map { (index, systemName) in
29 | let button = ActionButton(systemName: systemName)
30 | button.addTarget(self, action: #selector(textStyle(_:)), for: .touchUpInside)
31 | button.tag = index + 1
32 | return button
33 | }
34 | }()
35 |
36 | /// increase and decrease font size button, and font size label
37 | private lazy var fontSizeAdjustmentItems: [UIView] = {
38 | let size: CGFloat = 27
39 | let textFontSizeLabel = UILabel()
40 | textFontSizeLabel.textAlignment = .center
41 | textFontSizeLabel.font = UIFont.systemFont(ofSize: size * 0.8, weight: .light)
42 | textFontSizeLabel.text = "\(Int(UIFont.systemFontSize))"
43 | textFontSizeLabel.textColor = .systemBlue
44 | textFontSizeLabel.translatesAutoresizingMaskIntoConstraints = false
45 | textFontSizeLabel.widthAnchor.constraint(equalToConstant: size * 1.1).isActive = true
46 |
47 | let decreaseFontSizeButton = ActionButton(systemName: "minus.circle")
48 | decreaseFontSizeButton.addTarget(self, action: #selector(decreaseFontSize), for: .touchUpInside)
49 | let increaseFontSizeButton = ActionButton(systemName: "plus.circle")
50 | increaseFontSizeButton.addTarget(self, action: #selector(increaseFontSize), for: .touchUpInside)
51 |
52 | return [decreaseFontSizeButton, textFontSizeLabel, increaseFontSizeButton]
53 | }()
54 |
55 | var textAlignment: NSTextAlignment = .left {
56 | didSet {
57 | alignmentButton.setImage(systemName: self.textAlignment.imageName)
58 | }
59 | }
60 |
61 | private lazy var alignmentButton: ActionButton = {
62 | let button = ActionButton(systemName: NSTextAlignment.left.imageName)
63 | button.addTarget(self, action: #selector(alignText(_:)), for: .touchUpInside)
64 | return button
65 | }()
66 |
67 | private lazy var insertImageButton: ActionButton = {
68 | let button = ActionButton(systemName: "photo.on.rectangle.angled")
69 | button.addTarget(self, action: #selector(insertImage(_:)), for: .touchUpInside)
70 | return button
71 | }()
72 |
73 | private lazy var fontSelectionButton: ActionButton = {
74 | let button = ActionButton(systemName: "textformat.size")
75 | button.addTarget(self, action: #selector(toggleFontPalette(_:)), for: .touchUpInside)
76 | return button
77 | }()
78 |
79 | private let colorSelectionSymbol = "pencil.tip"
80 | private lazy var colorSelectionButon: ActionButton = {
81 | var symbolName: String = self.colorSelectionSymbol
82 | if #available(iOS 14, *) {
83 | symbolName = "paintpalette"
84 | }
85 | let button = ActionButton(systemName: symbolName)
86 | if symbolName == self.colorSelectionSymbol {
87 | button.tintColor = ColorLibrary.textColors.first!
88 | }
89 | button.addTarget(self, action: #selector(toggleColorPalette(_:)), for: .touchUpInside)
90 | return button
91 | }()
92 |
93 | // Color Palette Bar
94 | private let colorPaletteBarHeight: CGFloat = 33
95 | private lazy var colorPaletteBar: UIStackView = {
96 | let colorButtons = ColorLibrary.textColors.map { color in
97 | let button = ActionButton(systemName: "circle.fill", size: 24)
98 | button.tintColor = color
99 | button.addTarget(self, action: #selector(selectTextColor(_:)), for: .touchUpInside)
100 | return button
101 | }
102 |
103 | let containerView = UIStackView(arrangedSubviews: colorButtons)
104 | containerView.axis = .horizontal
105 | containerView.alignment = .center
106 | containerView.spacing = edgePadding / 2
107 | containerView.backgroundColor = .clear
108 | containerView.sizeToFit()
109 |
110 | return containerView
111 | }()
112 |
113 | // TODO: Support Fonts Selection
114 |
115 | private lazy var fontPaletteBar: UIStackView = {
116 | let containerView = UIStackView()
117 | return containerView
118 | }()
119 |
120 | // MARK: Initialization
121 |
122 | var toolbarItems: [UIView] = []
123 | var toolbarStack: UIView!
124 |
125 | init(accessorySections: Array) {
126 | self.accessorySections = accessorySections
127 | super.init(frame: .zero)
128 |
129 | self.setupAccessoryView()
130 | }
131 |
132 | required init(coder: NSCoder) {
133 | fatalError("init(coder:) has not been implemented")
134 | }
135 |
136 | private func setupAccessoryView() {
137 | self.axis = .vertical
138 | self.alignment = .leading
139 | self.distribution = .fillProportionally
140 | self.backgroundColor = .secondarySystemBackground
141 | self.setupToolBar()
142 | }
143 |
144 | private let toolbarHeight: CGFloat = 44
145 | private func setupToolBar() {
146 | self.toolbarStack = UIView()
147 |
148 | self.configureItems()
149 | self.addArrangedSubview(self.toolbarStack)
150 | self.toolbarStack.backgroundColor = .clear
151 | self.toolbarStack.translatesAutoresizingMaskIntoConstraints = false
152 | NSLayoutConstraint.activate([
153 | self.toolbarStack.leadingAnchor.constraint(equalTo: self.leadingAnchor),
154 | self.toolbarStack.trailingAnchor.constraint(equalTo: self.trailingAnchor)
155 | ])
156 |
157 | let scrollableBar = ScrollableToolbar(items: self.toolbarItems)
158 | scrollableBar.backgroundColor = .clear
159 | let keyboardEscape = UIButton()
160 | let symbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .light)
161 | let keyboardSymbolName = "keyboard.chevron.compact.down"
162 | keyboardEscape.layer.borderWidth = 1
163 | keyboardEscape.layer.cornerRadius = 5
164 | keyboardEscape.layer.borderColor = UIColor.systemBlue.cgColor
165 | keyboardEscape.addTarget(self, action: #selector(hideKeyboard), for: .touchUpInside)
166 | keyboardEscape.setImage(UIImage(systemName: keyboardSymbolName, withConfiguration: symbolConfiguration), for: .normal)
167 |
168 | self.toolbarStack.addSubview(scrollableBar)
169 | scrollableBar.translatesAutoresizingMaskIntoConstraints = false
170 | self.toolbarStack.addSubview(keyboardEscape)
171 | keyboardEscape.translatesAutoresizingMaskIntoConstraints = false
172 | NSLayoutConstraint.activate([
173 | scrollableBar.leadingAnchor.constraint(equalTo: self.toolbarStack.leadingAnchor),
174 | scrollableBar.trailingAnchor.constraint(equalTo: keyboardEscape.leadingAnchor),
175 | scrollableBar.topAnchor.constraint(equalTo: self.toolbarStack.topAnchor),
176 | scrollableBar.bottomAnchor.constraint(equalTo: self.toolbarStack.bottomAnchor),
177 | scrollableBar.heightAnchor.constraint(equalTo: self.toolbarStack.heightAnchor),
178 |
179 | keyboardEscape.trailingAnchor.constraint(equalTo: self.toolbarStack.trailingAnchor),
180 | keyboardEscape.centerYAnchor.constraint(equalTo: self.toolbarStack.centerYAnchor),
181 | keyboardEscape.heightAnchor.constraint(equalTo: self.toolbarStack.heightAnchor),
182 | keyboardEscape.widthAnchor.constraint(equalTo: keyboardEscape.heightAnchor)
183 | ])
184 | }
185 |
186 | private func configureItems() {
187 | if accessorySections.contains(.textStyle) {
188 | self.toolbarItems += textStyleItems
189 | }
190 | if accessorySections.contains(.fontAdjustment) {
191 | self.toolbarItems += fontSizeAdjustmentItems
192 | }
193 | if accessorySections.contains(.textAlignment) {
194 | self.toolbarItems.append(alignmentButton)
195 | }
196 | if accessorySections.contains(.image) {
197 | self.toolbarItems.append(insertImageButton)
198 | }
199 | if accessorySections.contains(.colorPalette) {
200 | self.toolbarItems.append(colorSelectionButon)
201 | }
202 | }
203 |
204 | var isDisplayColorPalette: Bool {
205 | return self.accessorySections.contains(.colorPalette)
206 | }
207 |
208 | // MARK: Actions
209 |
210 | @objc private func toggleFontPalette(_ button: UIButton) {
211 | //
212 | }
213 |
214 | @objc private func toggleColorPalette(_ button: UIButton) {
215 | let hasAdditionalBar = self.arrangedSubviews.contains(colorPaletteBar)
216 | if hasAdditionalBar {
217 | self.colorPaletteBar.removeFromSuperview()
218 | } else {
219 | self.insertArrangedSubview(colorPaletteBar, at: 0)
220 | }
221 | // Get input accessory view's height constraint, default is equal to constant 44
222 | let constraint = self.constraints[2]
223 | constraint.constant = !hasAdditionalBar ? self.toolbarHeight + self.colorPaletteBarHeight : self.toolbarHeight
224 | }
225 |
226 | @objc private func hideKeyboard(_ button: UIButton) {
227 | delegate.hideKeyboard()
228 | }
229 |
230 | @objc private func textStyle(_ button: UIButton) {
231 | if button.tag == 1 {
232 | delegate.textBold()
233 | } else if button.tag == 2 {
234 | delegate.textItalic()
235 | } else if button.tag == 3 {
236 | delegate.textUnderline()
237 | } else if button.tag == 4 {
238 | delegate.textStrike()
239 | }
240 | }
241 |
242 | @objc private func alignText(_ button: UIButton) {
243 | switch textAlignment {
244 | case .left: textAlignment = .center
245 | case .center: textAlignment = .right
246 | case .right: textAlignment = .left
247 | case .justified: textAlignment = .justified
248 | case .natural: textAlignment = .natural
249 | @unknown default: textAlignment = .left
250 | }
251 | delegate.textAlign(align: textAlignment)
252 | }
253 |
254 | @objc private func increaseFontSize() {
255 | delegate.adjustFontSize(isIncrease: true)
256 | }
257 |
258 | @objc private func decreaseFontSize() {
259 | delegate.adjustFontSize(isIncrease: false)
260 | }
261 |
262 | @objc private func textFont(font: String) {
263 | delegate.textFont(name: font)
264 | }
265 |
266 | @objc private func insertImage(_ button: UIButton) {
267 | delegate.insertImage()
268 | }
269 |
270 | @objc private func selectTextColor(_ button: UIButton) {
271 | delegate.textColor(color: button.tintColor)
272 | }
273 |
274 | /// chech if a button should be highlighted, when user clicked a button or selected text contain the effect current button corresponded to
275 | private func selectedButton(_ button: UIButton, isSelected: Bool) {
276 | button.layer.cornerRadius = isSelected ? cornerRadius : 0
277 | button.layer.backgroundColor = isSelected ? selectedColor.cgColor : UIColor.clear.cgColor
278 | }
279 |
280 | // MARK: Update Tool Bar States
281 |
282 | /// update toolbar buttons and colors based on current typing attributes
283 | func updateToolbar(typingAttributes: [NSAttributedString.Key : Any], textAlignment: NSTextAlignment) {
284 | self.alignmentButton.setImage(systemName: textAlignment.imageName)
285 |
286 | for attribute in typingAttributes {
287 | if attribute.key == .font {
288 | self.updateFontRelatedItems(attributeValue: attribute.value)
289 | }
290 |
291 | if attribute.key == .underlineStyle {
292 | self.updateTextStrikeItems(attributeValue: attribute.value, index: 2)
293 | }
294 |
295 | if attribute.key == .strikethroughStyle {
296 | self.updateTextStrikeItems(attributeValue: attribute.value, index: 3)
297 | }
298 |
299 | if attribute.key == .foregroundColor {
300 | self.updateColorPaletteItems(attributeValue: attribute.value)
301 | }
302 | }
303 | }
304 |
305 | /// update font size and text bold, italic effects
306 | private func updateFontRelatedItems(attributeValue: Any) {
307 | let boldButton = self.textStyleItems[0] as! UIButton
308 | let italicButton = self.textStyleItems[1] as! UIButton
309 |
310 | if let font = attributeValue as? UIFont {
311 | let fontSize = font.pointSize
312 |
313 | (self.fontSizeAdjustmentItems[1] as! UILabel).text = "\(Int(fontSize))"
314 | let isBold = (font == UIFont.boldSystemFont(ofSize: fontSize))
315 | let isItalic = (font == UIFont.italicSystemFont(ofSize: fontSize))
316 | self.selectedButton(boldButton, isSelected: isBold)
317 | self.selectedButton(italicButton, isSelected: isItalic)
318 | } else {
319 | self.selectedButton(boldButton, isSelected: false)
320 | self.selectedButton(italicButton, isSelected: false)
321 | }
322 | }
323 |
324 | /// update text underline and strike through effects
325 | private func updateTextStrikeItems(attributeValue: Any, index: Int) {
326 | let strikeButton = self.textStyleItems[index] as! UIButton
327 | if let style = attributeValue as? Int {
328 | self.selectedButton(strikeButton, isSelected: style == NSUnderlineStyle.single.rawValue)
329 | } else {
330 | self.selectedButton(strikeButton, isSelected: false)
331 | }
332 | }
333 |
334 | /// update text color state
335 | private func updateColorPaletteItems(attributeValue: Any) {
336 | guard let currentTextColor = attributeValue as? UIColor else {
337 | return
338 | }
339 | if self.colorSelectionButon.symbolName == self.colorSelectionSymbol {
340 | self.colorSelectionButon.tintColor = currentTextColor
341 | }
342 |
343 | guard self.contains(colorPaletteBar) else {
344 | return
345 | }
346 | for item in self.colorPaletteBar.arrangedSubviews {
347 | let button = item as! ActionButton
348 | if button.tintColor == currentTextColor {
349 | button.setImage(systemName: "checkmark.circle.fill")
350 | } else {
351 | button.setImage(systemName: "circle.fill")
352 | }
353 | }
354 | }
355 | }
356 |
--------------------------------------------------------------------------------
/Sources/TextEditor/TextEditorWrapper.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Steven Zhang on 3/12/22.
6 | //
7 |
8 | import SwiftUI
9 |
10 | @available(iOS 13.0, *)
11 | struct TextEditorWrapper: UIViewControllerRepresentable {
12 | private var richText: NSMutableAttributedString
13 | @Binding private var height: CGFloat
14 |
15 | private var controller: UIViewController
16 | private var textView: UITextView
17 | private var accessoryView: InputAccessoryView
18 |
19 | private let placeholder: String
20 | private let lineSpacing: CGFloat = 3
21 | private let hintColor = UIColor.placeholderText
22 | private let defaultFontSize = UIFont.systemFontSize
23 | private let defaultFontName = "AvenirNext-Regular"
24 | private let onCommit: ((NSAttributedString) -> Void)
25 |
26 | private var defaultFont: UIFont {
27 | return UIFont(name: defaultFontName, size: defaultFontSize) ?? .systemFont(ofSize: defaultFontSize)
28 | }
29 |
30 | // TODO: line width, line style
31 | init(
32 | richText: NSMutableAttributedString,
33 | height: Binding,
34 | placeholder: String,
35 | sections: Array,
36 | onCommit: @escaping ((NSAttributedString) -> Void)
37 | ) {
38 | self.richText = richText
39 | self._height = height
40 | self.controller = UIViewController()
41 | self.textView = UITextView()
42 | self.placeholder = placeholder
43 | self.onCommit = onCommit
44 |
45 | self.accessoryView = InputAccessoryView(accessorySections: sections)
46 | }
47 |
48 | func makeUIViewController(context: Context) -> UIViewController {
49 | setUpTextView()
50 | textView.delegate = context.coordinator
51 | context.coordinator.textViewDidChange(textView)
52 |
53 | accessoryView.delegate = context.coordinator
54 | textView.inputAccessoryView = accessoryView
55 | return controller
56 | }
57 |
58 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
59 | self.accessoryView.frame = CGRect(x: 0, y: 0, width: 0, height: 44)
60 | }
61 |
62 | func makeCoordinator() -> Coordinator {
63 | Coordinator(self)
64 | }
65 |
66 | private func setUpTextView() {
67 | if richText.string == "" {
68 | textView.attributedText = NSAttributedString(string: placeholder, attributes: [.foregroundColor: hintColor])
69 | } else {
70 | textView.attributedText = richText
71 | }
72 | textView.typingAttributes = [.font : defaultFont]
73 | textView.isEditable = true
74 | textView.isSelectable = true
75 | textView.isScrollEnabled = false
76 | textView.isUserInteractionEnabled = true
77 | textView.textAlignment = .left
78 |
79 | textView.textContainerInset = UIEdgeInsets.zero
80 | textView.textContainer.lineFragmentPadding = 0
81 | textView.layoutManager.allowsNonContiguousLayout = false
82 | textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
83 | textView.backgroundColor = .clear
84 |
85 | controller.view.addSubview(textView)
86 | textView.translatesAutoresizingMaskIntoConstraints = false
87 | NSLayoutConstraint.activate([
88 | textView.centerXAnchor.constraint(equalTo: controller.view.centerXAnchor),
89 | textView.centerYAnchor.constraint(equalTo: controller.view.centerYAnchor),
90 | textView.widthAnchor.constraint(equalTo: controller.view.widthAnchor),
91 | ])
92 | }
93 |
94 | private func scaleImage(image: UIImage, maxWidth: CGFloat, maxHeight: CGFloat) -> UIImage {
95 | let ratio = image.size.width / image.size.height
96 | let imageW: CGFloat = (ratio >= 1) ? maxWidth : image.size.width*(maxHeight/image.size.height)
97 | let imageH: CGFloat = (ratio <= 1) ? maxHeight : image.size.height*(maxWidth/image.size.width)
98 | UIGraphicsBeginImageContext(CGSize(width: imageW, height: imageH))
99 | image.draw(in: CGRect(x: 0, y: 0, width: imageW, height: imageH))
100 | let scaledimage = UIGraphicsGetImageFromCurrentImageContext()
101 | UIGraphicsEndImageContext()
102 | return scaledimage!
103 | }
104 |
105 | class Coordinator: NSObject, UITextViewDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate, TextEditorDelegate {
106 | var parent: TextEditorWrapper
107 | var fontName: String
108 |
109 | private var isBold = false
110 | private var isItalic = false
111 |
112 | init(_ parent: TextEditorWrapper) {
113 | self.parent = parent
114 | self.fontName = parent.defaultFontName
115 | }
116 |
117 | // MARK: - Image Picker
118 |
119 | func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
120 | if let img = info[UIImagePickerController.InfoKey.editedImage] as? UIImage, var image = img.roundedImageWithBorder(color: .secondarySystemBackground) {
121 | textViewDidBeginEditing(parent.textView)
122 | let newString = NSMutableAttributedString(attributedString: parent.textView.attributedText)
123 | image = scaleImage(image: image, maxWidth: 180, maxHeight: 180)
124 |
125 | let textAttachment = NSTextAttachment(image: image)
126 | let attachmentString = NSAttributedString(attachment: textAttachment)
127 | newString.append(attachmentString)
128 | parent.textView.attributedText = newString
129 | textViewDidChange(parent.textView)
130 | }
131 | picker.dismiss(animated: true, completion: nil)
132 | }
133 |
134 | func scaleImage(image: UIImage, maxWidth: CGFloat, maxHeight: CGFloat) -> UIImage {
135 | let ratio = image.size.width / image.size.height
136 | let imageW: CGFloat = (ratio >= 1) ? maxWidth : image.size.width*(maxHeight/image.size.height)
137 | let imageH: CGFloat = (ratio <= 1) ? maxHeight : image.size.height*(maxWidth/image.size.width)
138 | UIGraphicsBeginImageContext(CGSize(width: imageW, height: imageH))
139 | image.draw(in: CGRect(x: 0, y: 0, width: imageW, height: imageH))
140 | let scaledimage = UIGraphicsGetImageFromCurrentImageContext()
141 | UIGraphicsEndImageContext()
142 | return scaledimage!
143 | }
144 |
145 | // MARK: - Text Editor Delegate
146 |
147 | /// - If user doesn't selecte any text, make follow typing text bold
148 | /// - if user selected text contain bold, then make them plain, else make them bold
149 | func textBold() {
150 | let attributes = parent.textView.selectedRange.isEmpty ? parent.textView.typingAttributes : selectedAttributes
151 | let fontSize = getFontSize(attributes: attributes)
152 |
153 | let defaultFont = UIFont.systemFont(ofSize: fontSize)
154 | textEffect(type: UIFont.self, key: .font, value: UIFont.boldSystemFont(ofSize: fontSize), defaultValue: defaultFont)
155 | }
156 |
157 | func textUnderline() {
158 | textEffect(type: Int.self, key: .underlineStyle, value: NSUnderlineStyle.single.rawValue, defaultValue: .zero)
159 | }
160 |
161 | func textItalic() {
162 | let attributes = parent.textView.selectedRange.isEmpty ? parent.textView.typingAttributes : selectedAttributes
163 | let fontSize = getFontSize(attributes: attributes)
164 |
165 | let defaultFont = UIFont.systemFont(ofSize: fontSize)
166 | textEffect(type: UIFont.self, key: .font, value: UIFont.italicSystemFont(ofSize: fontSize), defaultValue: defaultFont)
167 | }
168 |
169 | func textStrike() {
170 | textEffect(type: Int.self, key: .strikethroughStyle, value: NSUnderlineStyle.single.rawValue, defaultValue: .zero)
171 | }
172 |
173 | func textAlign(align: NSTextAlignment) {
174 | parent.textView.textAlignment = align
175 | }
176 |
177 | func adjustFontSize(isIncrease: Bool) {
178 | var font: UIFont
179 |
180 | let maxFontSize: CGFloat = 18
181 | let minFontSize: CGFloat = 10
182 |
183 | let attributes = parent.textView.selectedRange.isEmpty ? parent.textView.typingAttributes : selectedAttributes
184 | let size = getFontSize(attributes: attributes)
185 | let fontSize = size + CGFloat(isIncrease ? (size < maxFontSize ? 1 : 0) : (size > minFontSize ? -1 : 0))
186 | let defaultFont = UIFont.systemFont(ofSize: fontSize)
187 |
188 | if isContainBoldFont(attributes: attributes) {
189 | font = UIFont.boldSystemFont(ofSize: fontSize)
190 | } else if isContainItalicFont(attributes: attributes) {
191 | font = UIFont.italicSystemFont(ofSize: fontSize)
192 | } else {
193 | font = defaultFont
194 | }
195 |
196 | textEffect(type: UIFont.self, key: .font, value: font, defaultValue: defaultFont)
197 | }
198 |
199 | func textFont(name: String) {
200 | let attributes = parent.textView.selectedRange.isEmpty ? parent.textView.typingAttributes : selectedAttributes
201 | let fontSize = getFontSize(attributes: attributes)
202 |
203 | fontName = name
204 | let defaultFont = UIFont.systemFont(ofSize: fontSize)
205 | let newFont = UIFont(name: fontName, size: fontSize) ?? defaultFont
206 | textEffect(type: UIFont.self, key: .font, value: newFont, defaultValue: defaultFont)
207 | }
208 |
209 | func textColor(color: UIColor) {
210 | textEffect(type: UIColor.self, key: .foregroundColor, value: color, defaultValue: color)
211 | }
212 |
213 | func insertImage() {
214 | let sourceType = UIImagePickerController.SourceType.photoLibrary
215 | let imagePicker = UIImagePickerController()
216 | imagePicker.delegate = self
217 | imagePicker.allowsEditing = true
218 | imagePicker.sourceType = sourceType
219 | parent.controller.present(imagePicker, animated: true, completion: nil)
220 | }
221 |
222 | func insertLine(name: String) {
223 | if let line = UIImage(named: name) {
224 | let newString = NSMutableAttributedString(attributedString: parent.textView.attributedText)
225 | let image = scaleImage(image: line, maxWidth: 280, maxHeight: 20)
226 | let attachment = NSTextAttachment(image: image)
227 | let attachedString = NSAttributedString(attachment: attachment)
228 | newString.append(attachedString)
229 | parent.textView.attributedText = newString
230 | }
231 | }
232 |
233 | func hideKeyboard() {
234 | parent.textView.resignFirstResponder()
235 | }
236 |
237 | /// Add text attributes to text view
238 | /// - Returns:If text view's typing attributes revised, return true; if attributes are only for text in selected range, return false.
239 | private func textEffect(type: T.Type, key: NSAttributedString.Key, value: Any, defaultValue: T) {
240 | let range = parent.textView.selectedRange
241 | if !range.isEmpty {
242 | let isContain = isContain(type: type, range: range, key: key, value: value)
243 | let mutableString = NSMutableAttributedString(attributedString: parent.textView.attributedText)
244 | if isContain {
245 | mutableString.removeAttribute(key, range: range)
246 | if key == .font {
247 | mutableString.addAttributes([key : defaultValue], range: range)
248 | }
249 | } else {
250 | mutableString.addAttributes([key : value], range: range)
251 | }
252 | parent.textView.attributedText = mutableString
253 | } else {
254 | if let current = parent.textView.typingAttributes[key], current as! T == value as! T {
255 | parent.textView.typingAttributes[key] = defaultValue
256 | } else {
257 | parent.textView.typingAttributes[key] = value
258 | }
259 | parent.accessoryView.updateToolbar(typingAttributes: parent.textView.typingAttributes, textAlignment: parent.textView.textAlignment)
260 | }
261 | }
262 |
263 | /// Find specific attribute in the range of text which user selected
264 | /// - parameter range: Selected range in text view
265 | private func isContain(type: T.Type, range: NSRange, key: NSAttributedString.Key, value: Any) -> Bool {
266 | var isContain: Bool = false
267 | parent.textView.attributedText.enumerateAttributes(in: range) { attributes, range, stop in
268 | if attributes.filter({ $0.key == key }).contains(where: {
269 | $0.value as! T == value as! T
270 | }) {
271 | isContain = true
272 | stop.pointee = true
273 | }
274 | }
275 | return isContain
276 | }
277 |
278 | private func isContainBoldFont(attributes: [NSAttributedString.Key : Any]) -> Bool {
279 | return attributes.contains { attribute in
280 | if attribute.key == .font, let value = attribute.value as? UIFont {
281 | return value == UIFont.boldSystemFont(ofSize: value.pointSize)
282 | } else {
283 | return false
284 | }
285 | }
286 | }
287 |
288 | private func isContainItalicFont(attributes: [NSAttributedString.Key : Any]) -> Bool {
289 | return attributes.contains { attribute in
290 | if attribute.key == .font, let value = attribute.value as? UIFont {
291 | return value == UIFont.italicSystemFont(ofSize: value.pointSize)
292 | } else {
293 | return false
294 | }
295 | }
296 | }
297 |
298 | private func getFontSize(attributes: [NSAttributedString.Key : Any]) -> CGFloat {
299 | if let value = attributes[.font] as? UIFont {
300 | return value.pointSize
301 | } else {
302 | return UIFont.systemFontSize
303 | }
304 | }
305 |
306 | var selectedAttributes: [NSAttributedString.Key : Any] {
307 | let textRange = parent.textView.selectedRange
308 | if textRange.isEmpty {
309 | return [:]
310 | } else {
311 | var textAttributes: [NSAttributedString.Key : Any] = [:]
312 | parent.textView.attributedText.enumerateAttributes(in: textRange) { attributes, range, stop in
313 | for item in attributes {
314 | textAttributes[item.key] = item.value
315 | }
316 | }
317 | return textAttributes
318 | }
319 | }
320 |
321 |
322 | // MARK: - Text View Delegate
323 |
324 | func textViewDidChangeSelection(_ textView: UITextView) {
325 | let textRange = parent.textView.selectedRange
326 |
327 | if textRange.isEmpty {
328 | parent.accessoryView.updateToolbar(typingAttributes: parent.textView.typingAttributes, textAlignment: parent.textView.textAlignment)
329 | } else {
330 | parent.accessoryView.updateToolbar(typingAttributes: selectedAttributes, textAlignment: parent.textView.textAlignment)
331 | }
332 | }
333 |
334 | func textViewDidBeginEditing(_ textView: UITextView) {
335 | if textView.attributedText.string == parent.placeholder {
336 | textView.attributedText = NSAttributedString(string: "")
337 | textView.typingAttributes[.foregroundColor] = UIColor.black
338 | }
339 | }
340 |
341 | func textViewDidEndEditing(_ textView: UITextView) {
342 | if textView.attributedText.string == "" || textView.attributedText.string == parent.placeholder {
343 | textView.attributedText = NSAttributedString(string: parent.placeholder)
344 | } else {
345 | parent.onCommit(textView.attributedText)
346 | }
347 | }
348 |
349 | func textViewDidChange(_ textView: UITextView) {
350 | if textView.attributedText.string != parent.placeholder {
351 | parent.richText = NSMutableAttributedString(attributedString: textView.attributedText)
352 | }
353 | let size = CGSize(width: parent.controller.view.frame.width, height: .infinity)
354 | let estimatedSize = textView.sizeThatFits(size)
355 | if parent.height != estimatedSize.height {
356 | DispatchQueue.main.async {
357 | self.parent.height = estimatedSize.height
358 | }
359 | }
360 | textView.scrollRangeToVisible(textView.selectedRange)
361 | }
362 | }
363 | }
364 |
--------------------------------------------------------------------------------