├── .gitattributes ├── .github ├── PULL_REQUEST_TEMPLATE.md └── issue_template.md ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── LICENSE ├── Package.swift ├── README.md ├── README_ENG.md ├── Sources └── AutoHeightEditor │ ├── AutoHeightEditor.swift │ ├── Constant.swift │ └── Extension.swift └── Tests └── AutoHeightEditorTests └── AutoHeightEditorTests.swift /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pbxproj binary merge=union 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 개요 및 관련 이슈 2 | - 메인 홈 뷰의 UI를 전체 구현했습니다.(예시) 3 | 4 | ## 작업 사항 5 | - 관리자용 대시보드 구현(예시) 6 | - 관리자용 권한 수정 버튼 추가(예시) 7 | 8 | ## 주요 로직(Optional) 9 | ```diff 10 | - print("변경 전") 11 | + print("변경 후") 12 | ``` 13 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: 해당 Issue 대한 작업(Task)를 적어주세요! 4 | title: "" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### 📝 작업 목적 11 | 12 | [기능 혹은 버그에 대한 설명] 13 | 14 | **기대 효과:** [작업 기대 효과] 15 | 16 | **구현 효과:** [실제 구현 내용] 17 | 18 | --- 19 | 20 | ### 🛠️ 작업 체크리스트 21 | 22 | * [ ] line 1 23 | * [ ] line 2 24 | 25 | --- 26 | 27 | ### ⚙️ Working Env. 28 | 29 | - Target Ver : iOS 15.0 30 | - xcode 14.1 31 | -------------------------------------------------------------------------------- /.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 | 92 | # Created by https://www.toptal.com/developers/gitignore/api/swift,swiftpackagemanager,firebase,xcode,objective-c,cocoapods 93 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift,swiftpackagemanager,firebase,xcode,objective-c,cocoapods 94 | 95 | ### CocoaPods ### 96 | ## CocoaPods GitIgnore Template 97 | 98 | # CocoaPods - Only use to conserve bandwidth / Save time on Pushing 99 | # - Also handy if you have a large number of dependant pods 100 | # - AS PER https://guides.cocoapods.org/using/using-cocoapods.html NEVER IGNORE THE LOCK FILE 101 | Pods/ 102 | 103 | ### Firebase ### 104 | .idea 105 | **/node_modules/* 106 | **/.firebaserc 107 | 108 | ### Firebase Patch ### 109 | .runtimeconfig.json 110 | .firebase/ 111 | 112 | ### Objective-C ### 113 | # Xcode 114 | # 115 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 116 | 117 | ## User settings 118 | xcuserdata/ 119 | 120 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 121 | *.xcscmblueprint 122 | *.xccheckout 123 | 124 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 125 | build/ 126 | DerivedData/ 127 | *.moved-aside 128 | *.pbxuser 129 | !default.pbxuser 130 | *.mode1v3 131 | !default.mode1v3 132 | *.mode2v3 133 | !default.mode2v3 134 | *.perspectivev3 135 | !default.perspectivev3 136 | 137 | ## Obj-C/Swift specific 138 | *.hmap 139 | 140 | ## App packaging 141 | *.ipa 142 | *.dSYM.zip 143 | *.dSYM 144 | 145 | # CocoaPods 146 | # We recommend against adding the Pods directory to your .gitignore. However 147 | # you should judge for yourself, the pros and cons are mentioned at: 148 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 149 | # Pods/ 150 | # Add this line if you want to avoid checking in source code from the Xcode workspace 151 | # *.xcworkspace 152 | 153 | # Carthage 154 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 155 | # Carthage/Checkouts 156 | 157 | Carthage/Build/ 158 | 159 | # fastlane 160 | # It is recommended to not store the screenshots in the git repo. 161 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 162 | # For more information about the recommended setup visit: 163 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 164 | 165 | fastlane/report.xml 166 | fastlane/Preview.html 167 | fastlane/screenshots/**/*.png 168 | fastlane/test_output 169 | 170 | # Code Injection 171 | # After new code Injection tools there's a generated folder /iOSInjectionProject 172 | # https://github.com/johnno1962/injectionforxcode 173 | 174 | iOSInjectionProject/ 175 | 176 | ### Objective-C Patch ### 177 | 178 | ### Swift ### 179 | # Xcode 180 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 181 | 182 | 183 | 184 | 185 | 186 | 187 | ## Playgrounds 188 | timeline.xctimeline 189 | playground.xcworkspace 190 | 191 | # Swift Package Manager 192 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 193 | # Packages/ 194 | # Package.pins 195 | # Package.resolved 196 | 197 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 198 | # hence it is not needed unless you have added a package configuration file to your project 199 | # .swiftpm 200 | 201 | .build/ 202 | 203 | # CocoaPods 204 | # We recommend against adding the Pods directory to your .gitignore. However 205 | # you should judge for yourself, the pros and cons are mentioned at: 206 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 207 | # Pods/ 208 | # Add this line if you want to avoid checking in source code from the Xcode workspace 209 | # *.xcworkspace 210 | 211 | # Carthage 212 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 213 | # Carthage/Checkouts 214 | 215 | 216 | # Accio dependency management 217 | Dependencies/ 218 | .accio/ 219 | 220 | # fastlane 221 | # It is recommended to not store the screenshots in the git repo. 222 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 223 | # For more information about the recommended setup visit: 224 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 225 | 226 | 227 | # Code Injection 228 | # After new code Injection tools there's a generated folder /iOSInjectionProject 229 | # https://github.com/johnno1962/injectionforxcode 230 | 231 | 232 | ### SwiftPackageManager ### 233 | Packages 234 | xcuserdata 235 | 236 | 237 | 238 | ### Xcode ### 239 | 240 | ## Xcode 8 and earlier 241 | 242 | ### Xcode Patch ### 243 | *.xcodeproj/* 244 | !*.xcodeproj/project.pbxproj 245 | !*.xcodeproj/xcshareddata/ 246 | !*.xcodeproj/project.xcworkspace/ 247 | !*.xcworkspace/contents.xcworkspacedata 248 | /*.gcno 249 | **/xcshareddata/WorkspaceSettings.xcsettings 250 | 251 | # End of https://www.toptal.com/developers/gitignore/api/swift,swiftpackagemanager,firebase,xcode,objective-c,cocoapods 252 | 253 | # 프로젝트의 모든 경로에서 .DS_Store 파일을 포함하지 않도록 함 254 | **/.DS_Store 255 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Won Taeyoung 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: "AutoHeightEditor", 8 | platforms: [ 9 | .iOS(.v14) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, making them visible to other packages. 13 | .library( 14 | name: "AutoHeightEditor", 15 | targets: ["AutoHeightEditor"]), 16 | ], 17 | targets: [ 18 | // Targets are the basic building blocks of a package, defining a module or a test suite. 19 | // Targets can depend on other targets in this package and products from dependencies. 20 | .target( 21 | name: "AutoHeightEditor"), 22 | .testTarget( 23 | name: "AutoHeightEditorTests", 24 | dependencies: ["AutoHeightEditor"]), 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Platform](https://img.shields.io/badge/Platform-iOS_iPadOS-orange.svg)](https://developer.apple.com/ios/) 2 | [![Deployments](https://img.shields.io/badge/Deployments-14.0+-skyblue.svg)](https://developer.apple.com/documentation/ios-ipados-release-notes/ios-ipados-14-release-notes) 3 | [![UseFor](https://img.shields.io/badge/UseFor-SwiftUI-blue.svg)](https://developer.apple.com/xcode/swiftui/) 4 | [![SPM](https://img.shields.io/badge/SPM-Compatible-khaki.svg)](https://github.com/apple/swift-package-manager) 5 | [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/wontaeyoung/AutoHeightEditor/blob/main/LICENSE) 6 | [![Github](https://img.shields.io/badge/Author-wontaeyoung-red.svg)](https://www.github.com/wontaeyoung) 7 | 8 | [Read English Translation](https://github.com/wontaeyoung/AutoHeightEditor/blob/main/README_ENG.md) 9 | 10 |
11 | 12 | `AutoHeightEditor`는 Dynamic Height 기능이 있는 커스텀 `TextEditor` 라이브러리입니다. 13 | 14 |
15 | 16 | # 제작 배경 17 | 18 | 이 라이브러리는 제가 프로젝트에 필요해서 직접 구현하게 된 커스텀 `TextEditor`입니다. 19 | 20 | 제가 진행하고 있는 프로젝트에서 동적으로 높이가 조절되는 입력 인터페이스가 요구사항이었는데, **iOS 16**부터는 `TextField`의 `axis` 파라미터를 통해 Dynamic Height로 동작하는 입력 인터페이스를 쉽게 사용할 수 있습니다. 21 | 22 | 하지만 프로젝트 최소 지원버전이 **iOS 15.0**+로 결정되었고, 여러 줄의 텍스트 입력을 받기 위해서는 `TextEditor`를 사용해야했습니다. 23 | 24 | 기본 API로 제공되는 `TextEditor`를 사용해보신분들은 공감하시겠지만 지원하는 기능이 `TextField`에 비해 부족하고, 특히 별도로 높이를 지정해주지 않으면 차지할 수 있는 최대 높이를 가지게 됩니다. 25 | 26 | 이를 해결하기 위해서 적절한 높이를 동적으로 계산해주는 `AutoHeightEditor`를 커스텀으로 제작하게 되었습니다. 27 | 28 | 자세한 제작 배경 및 구현 과정은 [블로그](https://velog.io/@wontaeyoung/swiftui4)에서 확인할 수 있습니다. 29 | 30 |

31 | 32 | # 라이브러리 소개 33 | 34 | `AutoHeightEditor`는 기본적으로 Dynamic Height이 가장 큰 특징입니다. 35 | 36 | 이를 구현하기 위해 폰트 높이, 행간, 텍스트 길이, 개행문자를 통해서 적절한 높이로 TextEditor의 높이를 실시간으로 변경합니다. 37 | 38 |
39 | 40 | ## 주요 로직 41 | 42 | 1. 텍스트에서 `\n`(개행문자)의 갯수를 계산합니다. 43 | 2. `TextEditor`의 가로 길이와 입력된 텍스트의 길이를 계산해서 자동 줄바꿈이 몇 번 일어나야하는지 계산합니다. 44 | 3. 1번과 2번을 합쳐서 총 줄바꿈 횟수를 계산합니다. 45 | 4. 폰트 크기, 행간, 줄바꿈 수를 계산하여 `TextEditor`의 총 높이를 계산합니다. 46 | 47 |
48 | 49 | ## 사용자 편의 50 | 51 | 최소 1줄 ~ maxLine까지 사용자의 입력에 따라 높이가 변경되고 최대 라인 수, 사용되는 폰트, 행간, 활성화 여부와 같은 선택사항을 파라미터로 전달받아서 반영합니다. 52 | 53 | 개인적으로 사용하는 컴포넌트를 라이브러리에 맞춰서 수정한만큼, 제가 아닌 다른 사용자가 사용하는 환경을 고려하여 아래 사항들을 추가했습니다. 54 | 55 | - isEnabled를 바인딩 받아서 외부에서 활성화 여부 관리 가능 56 | - 고정으로 존재하는 Border 스트로크 사용 여부 선택 가능 57 | - Disabled 안내 문구 커스텀 가능 58 | - 전달받은 정규식 매치 여부를 계산해서 바인딩 된 Bool 변수에 반영 59 | 60 |
61 | 62 | 제작 배경에서 설명한 것처럼 **iOS 16**은 아직은 실무에 적용하기 부담스러운 버전이기 때문에, 이를 고려하여 `TextEditor`가 처음 나온 **iOS 14**부터 사용 가능하도록 구현했습니다. 63 | 64 |

65 | 66 | # 파라미터 리스트 67 | 68 | ```swift 69 | public init ( 70 | text: Binding, 71 | font: Font = .body, 72 | lineSpace: CGFloat = 2, 73 | maxLine: Int, 74 | hasBorder: Bool, 75 | isEnabled: Binding, 76 | disabledPlaceholder: String, 77 | regExpUse: RegExpUse 78 | ) 79 | ``` 80 | 81 |

82 | 83 | ```swift 84 | text: Binding 85 | ``` 86 | 87 | 에디터에 바인딩되는 입력 텍스트 문자열입니다. 외부에서 바인딩으로 주입해서 사용합니다. 88 | 89 |

90 | 91 | ```swift 92 | font: Font 93 | ``` 94 | 95 | 텍스트에 적용할 폰트 타입입니다. Default Value로 `body`가 주입되고, 원하는 다른 폰트가 있다면 주입해서 사용 가능합니다. 96 | 97 |

98 | 99 | ```swift 100 | lineSpace: CGFloat 101 | ``` 102 | 103 | 텍스트 라인 사이에 들어가는 행 간격입니다. Default Value로 2가 주입되고, 원하는 다른 값이 있다면 주입해서 사용 가능합니다. 104 | 105 |

106 | 107 | ```swift 108 | maxLine: Int 109 | ``` 110 | 111 | 에디터의 높이가 증가하는 상한선 라인 수입니다. 입력 라인이 늘어날 때 `maxLine`까지 에디터 높이가 증가하고, 그 이후로는 늘어나지 않습니다. 112 | 113 |

114 | 115 | ```swift 116 | hasBorder: Bool 117 | ``` 118 | 119 | 기본으로 제공되는 `Stroke`의 사용 여부를 결정합니다. 기본 `Stroke`는 Gray 컬러에 20의 CornerRadius 값을 가지고 있습니다. 120 | 121 |

122 | 123 | ```swift 124 | isEnabled: Binding 125 | ``` 126 | 127 | 에디터의 활성화 여부를 결정합니다. 외부에서 바인딩으로 주입하고, 조절해서 사용합니다. 128 | 129 |

130 | 131 | ```swift 132 | disabledPlaceholder: String 133 | ``` 134 | 135 | 에디터가 비활성화 되어있을 때, 사용자에게 안내하기 위한 문구입니다. 136 | 137 |

138 | 139 | ```swift 140 | public enum RegExpUse { 141 | case use(pattern: String, isMatched: Binding) 142 | case none 143 | } 144 | 145 | regExpUse: RegExpUse 146 | ``` 147 | 148 | 정규식 매치 여부를 사용하는지를 결정하는 타입입니다. 149 | 150 | 사용하지 않으면 `none`, 사용한다면 `use`를 전달합니다. 151 | 152 | `pattern`은 매칭에 사용할 정규식 패턴, `isMatched`는 외부에서 주입하고 활용할 바인딩 값입니다. 153 | 154 | 텍스트가 업데이트 될 때마다 정규식을 검사해서 `isMatched`에 전달된 바인딩 변수를 자동으로 업데이트합니다. 155 | 156 |

157 | 158 | # 사용 가이드 159 | 160 | 우선 기본적인 동작을 확인해보기 위해 `AutoHeightEditor`를 초기화 해보겠습니다. 161 | 162 | 처음에는 1줄 높이로 시작하고, 입력된 텍스트에 따라 최대 5줄까지 높이가 동적으로 늘어납니다. 163 | 164 | `\n`(개행문자)로 일어나는 줄바꿈 뿐만 아니라, 텍스트가 길어져서 자동으로 줄바꿈이 발생하는 순간도 감지해서 높이에 반영합니다. 165 | 166 | ```swift 167 | AutoHeightEditor( 168 | text: $text, 169 | maxLine: 5, 170 | hasBorder: true, 171 | isEnabled: $isEnabled, 172 | disabledPlaceholder: "This editor has been disabled", 173 | regExpUse: .none) 174 | ``` 175 | 176 | 177 | 178 |

179 | 180 | ### 최대 라인 수 수정하기 181 | 182 | `maxLine`을 조정해서 최대 높이 라인 수를 결정할 수 있습니다. 183 | 184 | 아래 예시에서는 `7`을 전달해서 7줄 높이까지 늘어나도록 해보겠습니다. 185 | 186 | ```swift 187 | AutoHeightEditor( 188 | text: $text, 189 | maxLine: 7, 190 | hasBorder: true, 191 | isEnabled: $isEnabled, 192 | disabledPlaceholder: "This editor has been disabled", 193 | regExpUse: .none) 194 | ``` 195 | 196 | 197 | 198 |

199 | 200 | ### 폰트와 행 간격 수정하기 201 | 202 | > 현재 버전에서는 SwiftUI의 기본 `Font` 타입에 없는 값은 사용이 불가합니다. 폰트의 사이즈를 구하기 위해 내부에서 `UIFont`와 1:1 매핑을 하기 때문입니다. 203 | 204 | `font`와 `lineSpace`에는 기본값으로 `body`와 `2`가 전달되고 있습니다. 205 | 206 | 원하는 값이 있다면 Default Value 대신에 새로운 값을 전달할 수 있습니다. 207 | 208 | 아래 예시에서는 `title2`와 `10`을 전달해서 폰트 사이즈를 키우고 행 간격도 넓혀보겠습니다. 209 | 210 | ```swift 211 | AutoHeightEditor( 212 | text: $text, 213 | font: .title2, 214 | lineSpace: 10, 215 | maxLine: 5, 216 | hasBorder: true, 217 | isEnabled: $isEnabled, 218 | disabledPlaceholder: "This editor has been disabled", 219 | regExpUse: .none) 220 | ``` 221 | 222 | 223 | 224 |

225 | 226 | ### Border Stroke 커스텀 227 | 228 | `hasBorder`를 통해 기본으로 제공되는 테두리 사용 여부를 결정할 수 있습니다. 229 | 230 | 기본 `Stroke`는 Gray 컬러에 20의 CornerRadius 값을 가지고 있습니다. 231 | 232 | 아래 예시에서는 `hasBorder`의 값을 false로 전달하여 테두리를 삭제해보겠습니다. 233 | 234 | ```swift 235 | AutoHeightEditor( 236 | text: $text, 237 | maxLine: 5, 238 | hasBorder: false, 239 | isEnabled: $isEnabled, 240 | disabledPlaceholder: "This editor has been disabled", 241 | regExpUse: .none) 242 | ``` 243 | 244 | 245 | 246 |

247 | 248 | 외부에서 `overlay`를 사용하여 원하는 디자인을 커스텀으로 작성할 수 있습니다. 249 | 250 | 아래 예시에서는 기본 테두리를 삭제하고, overlay로 사각형 스타일의 테두리를 그려보겠습니다. 251 | 252 | ```swift 253 | AutoHeightEditor( 254 | text: $text, 255 | maxLine: 5, 256 | hasBorder: false, 257 | isEnabled: $isEnabled, 258 | disabledPlaceholder: "This editor has been disabled", 259 | regExpUse: .none) 260 | .overlay { 261 | Rectangle() 262 | .stroke() 263 | } 264 | ``` 265 | 266 | 267 | 268 |

269 | 270 | ### 에디터 사용 활성화 관리 271 | 272 | `isEnabled`로 에디터의 터치 이벤트 수신 여부를 조정할 수 있습니다. 273 | 274 | 외부에서 관리하기 위해 바인딩으로 전달받아 사용합니다. 275 | 276 | `disabled`되면 `disabledPlaceholder`에 전달된 텍스트를 플레이스홀더로 표시합니다. 277 | 278 | 아래 예시에서는 isEnabled에 변수를 바인딩하고, Toggle로 외부에서 관리해보겠습니다. 279 | 280 | ```swift 281 | AutoHeightEditor( 282 | text: $text, 283 | maxLine: 5, 284 | hasBorder: true, 285 | isEnabled: $isEnabled, 286 | disabledPlaceholder: "This editor has been disabled", 287 | regExpUse: .none) 288 | ``` 289 | 290 | 291 | 292 |

293 | 294 | 만약에 따로 비활성화 하는 경우가 없다면 `.constant()`로 전달하고, `disabledPlaceholder`에는 빈 문자열을 전달하면 됩니다. 295 | 296 | ```swift 297 | AutoHeightEditor( 298 | text: $text, 299 | maxLine: 5, 300 | hasBorder: true, 301 | isEnabled: .constant(true), 302 | disabledPlaceholder: "", 303 | regExpUse: .none) 304 | ``` 305 | 306 |

307 | 308 | ### 정규식 사용 309 | 310 | `regExpUse` 열거형으로 정규식 사용 여부를 결정할 수 있습니다. 311 | 312 | 사용하지 않는다면 `none`, 사용한다면 `use`를 주입하면 됩니다. 313 | 314 | `use`에는 연관값으로 `pattern`과 `isMatched`를 전달할 수 있습니다. 315 | 316 | `pattern` 텍스트와 비교할 정규식 패턴 문자열입니다. 317 | 318 | `isMatched`는 외부에서 바인딩 받는 변수로, 내부에서 정규식 일치 여부를 업데이트 받습니다. 319 | 320 | 아래 예시에서는 이메일 패턴을 전달해보겠습니다. 321 | 322 | ```swift 323 | AutoHeightEditor( 324 | text: $text, 325 | maxLine: 5, 326 | hasBorder: true, 327 | isEnabled: $isEnabled, 328 | disabledPlaceholder: "This editor has been disabled", 329 | regExpUse: .use( 330 | pattern: #"^[a-zA-Z0-9+-\_.]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]{2,3}+$"#, 331 | isMatched: $isMatched)) 332 | ``` 333 | 334 | 335 | 336 |

337 | 338 | ### 포커스 관리 339 | 340 | `@FocusState`를 패키지 내부에 포함시키면 최소 지원버전이 **iOS 15**로 올라가기 때문에 포함시키지 않았습니다. 341 | 342 | 트레이드 오프를 생각해봤을 때, 파라미터로 전달받는 사용성보다 지원 버전을 낮추는게 더 메리트가 있다고 생각했습니다. 343 | 344 | 프로젝트 지원 버전이 15.0+인 사용자분들은 외부에서 `FocusState`를 사용해서 포커스를 관리할 수 있습니다. 345 | 346 | ```swift 347 | AutoHeightEditor( 348 | text: $text, 349 | maxLine: 5, 350 | hasBorder: true, 351 | isEnabled: $isEnabled, 352 | disabledPlaceholder: "This editor has been disabled", 353 | regExpUse: .none) 354 | .focused($isFocus) 355 | ``` 356 | 357 | 358 | 359 |

360 | 361 | ### 다크모드 지원 362 | 363 | 현재 버전에서는 내부 `foregroundColor`에서 `primary`를 전달해서 기본적인 다크모드 대응만 지원하고 있습니다. 364 | 365 | 기본 제공 `Stroke` 색상은 라이트 / 다크 모두 `gray` 고정입니다. 366 | 367 | 368 | 369 |

370 | 371 | # 라이센스 372 | 373 | `AutoHeightEditor`는 MIT 라이센스의 범위 내에서 사용 가능합니다. 374 | 375 | 자세한 정보는 [라이센스](https://github.com/wontaeyoung/AutoHeightEditor/blob/main/LICENSE)에서 확인해주세요. 376 | 377 |
378 | 379 | **작성자**: [원태영](https://github.com/wontaeyoung) 380 | -------------------------------------------------------------------------------- /README_ENG.md: -------------------------------------------------------------------------------- 1 | [![Platform](https://img.shields.io/badge/Platform-iOS_iPadOS-orange.svg)](https://developer.apple.com/ios/) 2 | [![Deployments](https://img.shields.io/badge/Deployments-14.0+-skyblue.svg)](https://developer.apple.com/documentation/ios-ipados-release-notes/ios-ipados-14-release-notes) 3 | [![UseFor](https://img.shields.io/badge/UseFor-SwiftUI-blue.svg)](https://developer.apple.com/xcode/swiftui/) 4 | [![SPM](https://img.shields.io/badge/SPM-Compatible-khaki.svg)](https://github.com/apple/swift-package-manager) 5 | [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/wontaeyoung/AutoHeightEditor/blob/main/LICENSE) 6 | [![Github](https://img.shields.io/badge/Author-wontaeyoung-red.svg)](https://www.github.com/wontaeyoung) 7 | 8 | **`AutoHeightEditor`** is a custom **`TextEditor`** library with Dynamic Height functionality. 9 | 10 |
11 | 12 | # **Background** 13 | 14 | I created this custom **`TextEditor`** because it was a requirement for a project I was working on. 15 | 16 | In the project, an input interface that dynamically adjusts in height was needed. From iOS 16, an input interface that works as Dynamic Height can be easily used through the **`axis`** parameter of **`TextField`**. 17 | 18 | However, the minimum supported version of the project was decided to be iOS 15.0+, and for multiple lines of text input, **`TextEditor`** had to be used. 19 | 20 | As those who have used the standard API **`TextEditor`** might agree, it lacks some features compared to **`TextField`**, especially it takes up the maximum possible height unless a specific height is designated. 21 | 22 | To solve this, I created the **`AutoHeightEditor`**, which dynamically calculates the appropriate height. 23 | 24 | You can check out the detailed background and implementation process on [my blog](https://velog.io/@wontaeyoung/swiftui4). 25 | 26 |

27 | 28 | # **Library Introduction** 29 | 30 | The main feature of **`AutoHeightEditor`** is essentially Dynamic Height. 31 | 32 | It changes the height of the TextEditor in real-time based on font height, line spacing, text length, and new line characters. 33 | 34 |
35 | 36 | ## **Main Logic** 37 | 38 | 1. Count the number of **`\n`** (new line characters) in the text. 39 | 2. Calculate the width of the **`TextEditor`** and the length of the entered text to determine how many times automatic line breaks should occur. 40 | 3. Combine the counts from steps 1 and 2 to calculate the total number of line breaks. 41 | 4. Calculate the total height of the **`TextEditor`** by considering the font size, line spacing, and number of line breaks. 42 | 43 |
44 | 45 | ## **User Convenience** 46 | 47 | The height changes from a minimum of 1 line to a maximum of **`maxLine`** based on user input. Options like maximum line numbers, used font, line spacing, and activation status are taken as parameters. 48 | 49 | As I customized a component for personal use to fit into a library, I considered the environment of other users and added the following features: 50 | 51 | - Accepts **`isEnabled`** binding for external control of activation status. 52 | - Option to choose the use of a fixed Border stroke. 53 | - Customizable disabled placeholder text. 54 | - Reflects the result of regular expression matching in a bound Bool variable. 55 | 56 |
57 | 58 | As mentioned in the production background, **iOS 16** is still a version that is burdensome to apply in practice. Therefore, it was implemented to be usable from **iOS 14**, where **`TextEditor`** first appeared. 59 | 60 |

61 | 62 | # **Parameter List** 63 | 64 | ```swift 65 | 66 | public init ( 67 | text: Binding, 68 | font: Font = .body, 69 | lineSpace: CGFloat = 2, 70 | maxLine: Int, 71 | hasBorder: Bool, 72 | isEnabled: Binding, 73 | disabledPlaceholder: String, 74 | regExpUse: RegExpUse 75 | ) 76 | ``` 77 | 78 |

79 | 80 | ```swift 81 | text: Binding 82 | ``` 83 | 84 | The text string bound to the editor. It is used by injecting a binding from the outside. 85 | 86 |

87 | 88 | ```swift 89 | font: Font 90 | ``` 91 | 92 | The font type applied to the text. **`body`** is injected as the Default Value, and if you have a different desired font, you can inject and use it. 93 | 94 |

95 | 96 | ```swift 97 | lineSpace: CGFloat 98 | ``` 99 | 100 | The line spacing between text lines. **`2`** is injected as the Default Value, and if you have a different desired value, you can inject and use it. 101 | 102 |

103 | 104 | ```swift 105 | maxLine: Int 106 | ``` 107 | 108 | The maximum number of lines for which the editor's height increases. As the input lines increase, the height of the editor grows up to **`maxLine`**, and then does not increase further. 109 | 110 |

111 | 112 | ```swift 113 | hasBorder: Bool 114 | ``` 115 | 116 | Determines whether to use the default provided **`Stroke`**. The basic **`Stroke`** comes with a Gray color and a CornerRadius of 20. 117 | 118 |

119 | 120 | ```swift 121 | isEnabled: Binding 122 | ``` 123 | 124 | Determines whether the editor is active. It is injected and used from the outside via binding. 125 | 126 |

127 | 128 | ```swift 129 | disabledPlaceholder: String 130 | ``` 131 | 132 | A message to guide the user when the editor is disabled. 133 | 134 |

135 | 136 | ```swift 137 | public enum RegExpUse { 138 | case use(pattern: String, isMatched: Binding) 139 | case none 140 | } 141 | 142 | regExpUse: RegExpUse 143 | ``` 144 | 145 | The type that determines whether to use regular expression matching. 146 | 147 | If not used, pass **`none`**; if used, pass **`use`**. 148 | 149 | **`pattern`** is the regular expression pattern to compare with the text, and **`isMatched`** is a binding variable to be injected and used from the outside. 150 | 151 | The text is checked against the regular expression whenever it is updated, and the bound variable **`isMatched`** is automatically updated. 152 | 153 |

154 | 155 | # **Usage Guide** 156 | 157 | Let's start by initializing **`AutoHeightEditor`** to check its basic functionality. 158 | 159 | Initially, it starts with a height of 1 line, and the height dynamically increases up to a maximum of 5 lines depending on the entered text. 160 | 161 | It detects not only line breaks caused by **`\n`** (new line characters) but also moments when the text becomes long enough to cause automatic line breaks, and reflects this in the height. 162 | 163 | ```swift 164 | AutoHeightEditor( 165 | text: $text, 166 | maxLine: 5, 167 | hasBorder: true, 168 | isEnabled: $isEnabled, 169 | disabledPlaceholder: "This editor has been disabled", 170 | regExpUse: .none) 171 | ``` 172 | 173 | 174 | 175 |

176 | 177 | ### **Modifying the Maximum Line Count** 178 | 179 | You can determine the maximum line height by adjusting **`maxLine`**. 180 | 181 | In the example below, we'll pass **`7`** to allow it to expand up to a height of 7 lines. 182 | 183 | ```swift 184 | AutoHeightEditor( 185 | text: $text, 186 | maxLine: 7, 187 | hasBorder: true, 188 | isEnabled: $isEnabled, 189 | disabledPlaceholder: "This editor has been disabled", 190 | regExpUse: .none) 191 | ``` 192 | 193 | 194 | 195 |

196 | 197 | ### **Modifying Font and Line Spacing** 198 | 199 | > In the current version, values not in SwiftUI's basic Font type are not available. This is because we do a 1:1 mapping with UIFont internally to get the font size. 200 | 201 | **`font`** and **`lineSpace`** come with the Default Values of **`body`** and **`2`**, respectively. 202 | 203 | If you have desired values other than these Default Values, you can inject new ones. 204 | 205 | In the example below, we'll pass **`title2`** and **`10`** to increase the font size and line spacing. 206 | 207 | ```swift 208 | AutoHeightEditor( 209 | text: $text, 210 | font: .title2, 211 | lineSpace: 10, 212 | maxLine: 5, 213 | hasBorder: true, 214 | isEnabled: $isEnabled, 215 | disabledPlaceholder: "This editor has been disabled", 216 | regExpUse: .none) 217 | ``` 218 | 219 | 220 | 221 |

222 | 223 | ### **Customizing the Border Stroke** 224 | 225 | You can decide whether to use the provided default border stroke with **`hasBorder`**. 226 | 227 | The basic **`Stroke`** is Gray in color with a CornerRadius of 20. 228 | 229 | In the example below, we'll set **`hasBorder`** to false to remove the border. 230 | 231 | ```swift 232 | AutoHeightEditor( 233 | text: $text, 234 | maxLine: 5, 235 | hasBorder: false, 236 | isEnabled: $isEnabled, 237 | disabledPlaceholder: "This editor has been disabled", 238 | regExpUse: .none) 239 | ``` 240 | 241 | 242 | 243 |

244 | 245 | You can use **`overlay`** from outside to write a custom design. 246 | 247 | In the example below, we'll delete the default border and draw a rectangular style border with overlay. 248 | 249 | ```swift 250 | AutoHeightEditor( 251 | text: $text, 252 | maxLine: 5, 253 | hasBorder: false, 254 | isEnabled: $isEnabled, 255 | disabledPlaceholder: "This editor has been disabled", 256 | regExpUse: .none) 257 | .overlay { 258 | Rectangle() 259 | .stroke() 260 | } 261 | ``` 262 | 263 | 264 | 265 |

266 | 267 | ### **Managing Editor Activation** 268 | 269 | **`isEnabled`** controls whether the editor receives touch events. 270 | 271 | It is injected and used from the outside via binding. 272 | 273 | When disabled, the text passed to **`disabledPlaceholder`** is displayed as a placeholder. 274 | 275 | In the example below, we'll bind a variable to isEnabled and manage it with a Toggle from the outside. 276 | 277 | ```swift 278 | AutoHeightEditor( 279 | text: $text, 280 | maxLine: 5, 281 | hasBorder: true, 282 | isEnabled: $isEnabled, 283 | disabledPlaceholder: "This editor has been disabled", 284 | regExpUse: .none) 285 | ``` 286 | 287 | 288 | 289 |

290 | 291 | If there is no separate case for disabling, you can pass **`.constant()`** and an empty string for **`disabledPlaceholder`**. 292 | 293 | ```swift 294 | AutoHeightEditor( 295 | text: $text, 296 | maxLine: 5, 297 | hasBorder: true, 298 | isEnabled: .constant(true), 299 | disabledPlaceholder: "", 300 | regExpUse: .none) 301 | ``` 302 | 303 |

304 | 305 | ### **Using Regular Expressions** 306 | 307 | **`regExpUse`** enum determines whether to use regular expression matching. 308 | 309 | If not used, pass **`none`**; if used, pass **`use`**. 310 | 311 | **`use`** allows you to pass associated values **`pattern`** and **`isMatched`**. 312 | 313 | **`pattern`** is the regular expression pattern string to be compared with the text. 314 | 315 | **`isMatched`** is a binding variable injected and used from the outside. 316 | 317 | In the example below, we'll pass an email pattern. 318 | 319 | ```swift 320 | AutoHeightEditor( 321 | text: $text, 322 | maxLine: 5, 323 | hasBorder: true, 324 | isEnabled: $isEnabled, 325 | disabledPlaceholder: "This editor has been disabled", 326 | regExpUse: .use( 327 | pattern: #"^[a-zA-Z0-9+-\_.]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]{2,3}+$"#, 328 | isMatched: $isMatched)) 329 | ``` 330 | 331 | 332 | 333 |

334 | 335 | ### **Focus Management** 336 | 337 | I chose not to include **`@FocusState`** within the package to keep the minimum supported version at **iOS 14** rather than raising it to **iOS 15**. 338 | 339 | After considering the trade-offs, I thought it more beneficial to lower the version support than to enhance usability by passing parameters. 340 | 341 | Users with project support versions of 15.0+ can manage focus externally using **`FocusState`**. 342 | 343 | ```swift 344 | AutoHeightEditor( 345 | text: $text, 346 | maxLine: 5, 347 | hasBorder: true, 348 | isEnabled: $isEnabled, 349 | disabledPlaceholder: "This editor has been disabled", 350 | regExpUse: .none) 351 | .focused($isFocus) 352 | ``` 353 | 354 | 355 | 356 |

357 | 358 | ### **Dark Mode Support** 359 | 360 | The current version only supports basic dark mode adaptation by passing **`primary`** to the internal **`foregroundColor`**. 361 | 362 | The default provided **`Stroke`** color remains fixed at **`gray`** for both light and dark modes. 363 | 364 | 365 | 366 |

367 | 368 | # **License** 369 | 370 | **`AutoHeightEditor`** is available under the MIT license. 371 | 372 | Please see the [License](https://github.com/wontaeyoung/AutoHeightEditor/blob/main/LICENSE) for more information. 373 | 374 |
375 | 376 | **Author**: [Won Tae-young](https://github.com/wontaeyoung) 377 | -------------------------------------------------------------------------------- /Sources/AutoHeightEditor/AutoHeightEditor.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct AutoHeightEditor: View { 4 | public enum RegExpUse { 5 | case use(pattern: String, isMatched: Binding) 6 | case none 7 | } 8 | 9 | // MARK: - Property 10 | private let const = TextEditorConst.self 11 | 12 | private let text: Binding 13 | private let font: Font 14 | private let lineSpace: CGFloat 15 | private let isEnabled: Binding 16 | private let hasBorder: Bool 17 | private let disabledPlaceholder: String 18 | private let regExpUse: RegExpUse 19 | 20 | // MARK: Initializer에서 계산을 통해 결정되는 프로퍼티 21 | private let maxLineCount: CGFloat 22 | private let uiFont: UIFont 23 | private let maxHeight: CGFloat 24 | 25 | @State private var currentTextEditorHeight: CGFloat = 0 26 | @State private var maxTextWidth: CGFloat = 0 27 | 28 | // MARK: - Initializer 29 | /// 파라미터 font = .body, lineSpace = 2 기본값 지정 30 | public init ( 31 | text: Binding, 32 | font: Font = .body, 33 | lineSpace: CGFloat = 2, 34 | maxLine: Int, 35 | hasBorder: Bool, 36 | isEnabled: Binding, 37 | disabledPlaceholder: String, 38 | regExpUse: RegExpUse 39 | ) { 40 | // MARK: Required 41 | self.text = text 42 | self.font = font 43 | self.lineSpace = lineSpace 44 | self.isEnabled = isEnabled 45 | self.hasBorder = hasBorder 46 | self.disabledPlaceholder = disabledPlaceholder 47 | self.regExpUse = regExpUse 48 | 49 | // MARK: Calculated 50 | self.maxLineCount = (maxLine < 1 ? 1 : maxLine).asFloat 51 | self.uiFont = UIFont.fontToUIFont(from: font) 52 | self.maxHeight = (maxLineCount * (uiFont.lineHeight + lineSpace)) + const.TEXTEDITOR_FRAME_HEIGHT_FREESPACE 53 | } 54 | 55 | // MARK: - View 56 | public var body: some View { 57 | if isEnabled.wrappedValue { 58 | enabledEditor 59 | } else { 60 | disabledEditor 61 | } 62 | } 63 | } 64 | 65 | // MARK: - Calculate Line 66 | private extension AutoHeightEditor { 67 | /// 현재 text에 개행문자에 의한 라인 갯수가 몇 줄인지 계산합니다. 68 | var newLineCount: CGFloat { 69 | let currentText: String = text.wrappedValue 70 | let currentLineCount: Int = currentText 71 | .filter { $0 == "\n" } 72 | .count + 1 73 | let newLineCount: CGFloat = currentLineCount > maxLineCount.asInt 74 | ? maxLineCount 75 | : currentLineCount.asFloat 76 | 77 | return newLineCount 78 | } 79 | 80 | /// 개행 문자 기준으로 텍스트를 분리하고, 각 텍스트 길이가 Editor 길이를 초과하는지 체크하여 필요한 줄바꿈 수를 계산합니다. 81 | var autoLineCount: CGFloat { 82 | var counter: Int = 0 83 | text 84 | .wrappedValue 85 | .components(separatedBy: "\n") 86 | .forEach { line in 87 | let label = UILabel() 88 | label.font = .fontToUIFont(from: font) 89 | label.text = line 90 | label.sizeToFit() 91 | let currentTextWidth = label.frame.width 92 | counter += (currentTextWidth / maxTextWidth).asInt 93 | } 94 | 95 | return counter.asFloat 96 | } 97 | } 98 | 99 | // MARK: - Calculate Width / Height 100 | private extension AutoHeightEditor { 101 | /// textEditor 시작 높이를 설정합니다. 102 | func setTextEditorStartHeight() { 103 | currentTextEditorHeight = uiFont.lineHeight + const.TEXTEDITOR_FRAME_HEIGHT_FREESPACE 104 | } 105 | 106 | /// text가 가질 수 있는 최대 길이를 설정합니다. 107 | func setMaxTextWidth(proxy: GeometryProxy) { 108 | maxTextWidth = proxy.size.width - (const.TEXTEDITOR_INSET_HORIZONTAL * 2 + const.TEXTEDITOR_WIDTH_HORIZONTAL_BUFFER) 109 | } 110 | 111 | /// line count를 통해 textEditor 현재 높이를 계산해서 업데이트합니다. 112 | func updateTextEditorCurrentHeight() { 113 | // 총 라인 갯수 114 | let totalLineCount = newLineCount + autoLineCount 115 | 116 | // 총 라인 갯수가 maxCount 이상이면 최대 높이로 고정 117 | guard totalLineCount < maxLineCount else { 118 | currentTextEditorHeight = maxHeight 119 | return 120 | } 121 | 122 | // 라인 갯수로 계산한 현재 Editor 높이 123 | let currentHeight = (totalLineCount * (uiFont.lineHeight + lineSpace)) 124 | - lineSpace + const.TEXTEDITOR_FRAME_HEIGHT_FREESPACE 125 | 126 | // View의 높이를 결정하는 State 변수에 계산된 현재 높이를 할당하여 뷰에 반영 127 | currentTextEditorHeight = currentHeight 128 | } 129 | } 130 | 131 | // MARK: - Regular Expression 132 | private extension AutoHeightEditor { 133 | /// 정규식 프로퍼티와 현재 텍스트가 일치하는지 제공하는 인터페이스 프로퍼티 134 | private var isPatternMatched: Bool { 135 | switch regExpUse { 136 | case .use(let pattern, _): 137 | guard text.wrappedValue.isEmpty == false else { 138 | return false 139 | } 140 | 141 | let isMathed: Bool = text.wrappedValue.range( 142 | of: pattern, 143 | options: .regularExpression) != nil 144 | 145 | return isMathed 146 | 147 | case .none: 148 | return true 149 | } 150 | } 151 | 152 | /// 바인딩 되어있는 isPatternMatched에 정규식 패턴 매치 여부를 업데이트합니다. 153 | func updatePatternMatched() { 154 | switch regExpUse { 155 | case .use(_, let isMatched): 156 | isMatched.wrappedValue = self.isPatternMatched 157 | 158 | case .none: 159 | break 160 | } 161 | } 162 | } 163 | 164 | // MARK: - Editor View 165 | private extension AutoHeightEditor { 166 | var enabledEditor: some View { 167 | GeometryReader { proxy in 168 | ZStack { 169 | TextEditor(text: text) 170 | .autocorrectionDisabled() 171 | .autocapitalization(.none) 172 | .modifier( 173 | AutoHeightEditorLayoutModifier( 174 | font: font, 175 | color: .primary, 176 | lineSpace: lineSpace, 177 | maxHeight: currentTextEditorHeight, 178 | horizontalInset: const.TEXTEDITOR_INSET_HORIZONTAL, 179 | bottomInset: const.TEXTEDITOR_INSET_BOTTOM)) 180 | 181 | if hasBorder { 182 | RoundedRectangle(cornerRadius: const.TEXTEDITOR_STROKE_CORNER_RADIUS) 183 | .stroke() 184 | .foregroundColor(.gray) 185 | } 186 | } 187 | .onAppear { 188 | setTextEditorStartHeight() 189 | setMaxTextWidth(proxy: proxy) 190 | } 191 | .onChange(of: text.wrappedValue) { _ in 192 | updateTextEditorCurrentHeight() 193 | updatePatternMatched() 194 | } 195 | } 196 | .frame(maxHeight: currentTextEditorHeight) 197 | } 198 | 199 | var disabledEditor: some View { 200 | ZStack { 201 | TextEditor( 202 | text: .constant(disabledPlaceholder) 203 | ) 204 | .modifier( 205 | AutoHeightEditorLayoutModifier( 206 | font: font, 207 | color: .primary, 208 | lineSpace: lineSpace, 209 | maxHeight: currentTextEditorHeight, 210 | horizontalInset: const.TEXTEDITOR_INSET_HORIZONTAL, 211 | bottomInset: const.TEXTEDITOR_INSET_BOTTOM)) 212 | .disabled(true) 213 | 214 | if hasBorder { 215 | RoundedRectangle(cornerRadius: const.TEXTEDITOR_STROKE_CORNER_RADIUS) 216 | .stroke() 217 | .foregroundColor(.gray) 218 | .background( 219 | Color.gray.opacity(0.7)) 220 | .cornerRadius(const.TEXTEDITOR_STROKE_CORNER_RADIUS) 221 | } 222 | } 223 | .frame(maxHeight: currentTextEditorHeight) 224 | .onAppear { 225 | setTextEditorStartHeight() 226 | } 227 | } 228 | } 229 | 230 | private struct AutoHeightEditorLayoutModifier: ViewModifier { 231 | let font: Font 232 | let color: Color 233 | let lineSpace: CGFloat 234 | let maxHeight: CGFloat 235 | let horizontalInset: CGFloat 236 | let bottomInset: CGFloat 237 | 238 | func body(content: Content) -> some View { 239 | content 240 | .font(font) 241 | .foregroundColor(color) 242 | .lineSpacing(lineSpace) 243 | .frame(maxHeight: maxHeight) 244 | .padding(.horizontal, horizontalInset) 245 | .padding(.bottom, bottomInset) 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /Sources/AutoHeightEditor/Constant.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum TextEditorConst { 4 | static let TEXTEDITOR_DEFAULT_LINE_COUNT: Int = 1 5 | static let TEXTEDITOR_MAX_LINE_COUNT: Int = 5 6 | static let TEXTEDITOR_INSET_HORIZONTAL: CGFloat = 10 7 | static let TEXTEDITOR_WIDTH_HORIZONTAL_BUFFER: CGFloat = 17 8 | static let TEXTEDITOR_INSET_BOTTOM: CGFloat = 0 9 | static let TEXTEDITOR_STROKE_CORNER_RADIUS: CGFloat = 20 10 | static let TEXTEDITOR_FRAME_HEIGHT_FREESPACE: CGFloat = 20 11 | } 12 | -------------------------------------------------------------------------------- /Sources/AutoHeightEditor/Extension.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension CGFloat { 4 | var asInt: Int { 5 | switch self { 6 | case .infinity, .nan: 7 | return 0 8 | default: 9 | return Int(self) 10 | } 11 | } 12 | } 13 | 14 | extension Int { 15 | var asFloat: CGFloat { 16 | return CGFloat(self) 17 | } 18 | } 19 | 20 | extension UIFont { 21 | /// SwiftUI Font를 UIFont 타입으로 변환합니다. 22 | static func fontToUIFont(from font: Font) -> UIFont { 23 | let style: UIFont.TextStyle 24 | 25 | switch font { 26 | case .largeTitle: style = .largeTitle 27 | case .title: style = .title1 28 | case .title2: style = .title2 29 | case .title3: style = .title3 30 | case .headline: style = .headline 31 | case .subheadline: style = .subheadline 32 | case .callout: style = .callout 33 | case .caption: style = .caption1 34 | case .caption2: style = .caption2 35 | case .footnote: style = .footnote 36 | case .body: style = .body 37 | default: style = .body 38 | } 39 | 40 | return UIFont.preferredFont(forTextStyle: style) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/AutoHeightEditorTests/AutoHeightEditorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import AutoHeightEditor 3 | 4 | final class AutoHeightEditorTests: XCTestCase { 5 | func testExample() throws { 6 | // XCTest Documentation 7 | // https://developer.apple.com/documentation/xctest 8 | 9 | // Defining Test Cases and Test Methods 10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods 11 | } 12 | } 13 | --------------------------------------------------------------------------------