swiftUI의 textEditor와 cursor position(무한 스크롤)

라무·2024년 3월 28일

무한정 길이가 늘어나는 textEditor일때 커서가 내려가면 scrollView의 스크롤 위치도 그에 따라 변하게 하고 싶었다
그래서 열심히 찾아봤지만 순수 SwiftUI를 통해서는 구현이 힘들었고 Introspect라이브러리를 이용해서 구현했다!

예시 완성본

textEditor란?

uiKit에서 textView와 비슷한 역할을 하는 애라고 생각하면 된다!(물론 uikit의 textView에 비해서 없는 기능이 많아서 완전 순정으로 모든 uikit의 textView구현하는 거는 힘들 수도,,,)

애플 문서에서는 'A view that can display and edit long-form text.'라고 나와있다. 즉, 긴문자를 편집하거나 보여주는데 사용하는 뷰이다.

더 자세한 내용은 애플 공식문서를 참고하자!

Introspect란?

SwiftUI만 이용해서 해당 기능을 구현하는 것은 아직 없는 것는 것 같아서 해당 라이브러리를 이용했는데 IntroSpect는 SwiftUI에서 쉽게 UIKit의 기능을 사용할 수 있도록 해주는 라이브러리다.
원래 SwiftUI에서 UIKit을 사용하려면 UIRepresentable이나 별도 커스텀뷰를 이용했어야 했는데 그런 번거로운 작업을 하지 않아도 쉽게 사용할 수 있도록 도와주는 라이브러리다.
but,,, 약간 불편할 수 있고 취향을 타는 라이브러리라고 한다. 그리고 후에 지원이 중단될 수 있다는 단점이 있다!

그럼 textEditor랑 cursor position을 일치시키는 작업을 해보자

일단 찾아본 결과 순수하게 SwiftUI만 이용해서 구현할 수 있는 방법은 없는 것 같았다(애플 빨리 추가해조,,)
그래서 SwiftUI + introspect라이브러리를 조합해서 구현했다

1. Intrsopect install하기

두가지 방법이 있다. 첫번째는 spm 두번째는 cocoapods으로 설치하기
나 같은 경우에는 cocoapods으로 설치했다.

pod 'SwiftUIIntrospect', '~> 1.0'

혹은

let package = Package(
    dependencies: [
        .package(url: "https://github.com/siteline/swiftui-introspect", from: "1.0.0"),
    ],
    targets: [
        .target(name: <#Target Name#>, dependencies: [
            .product(name: "SwiftUIIntrospect", package: "swiftui-introspect"),
        ]),
    ]
)

설치하고 사용하려고 하는 view에 import를 해주면 된다

import SwiftUI
import SwiftUIIntrospect

2. TextEditor에서 textView를 가지고 올 수 있도록 한다

  1. @State 변수로 UITextView를 만들어서 intropect가 가져올 uiTextView를 받아준다
    textView를 가지고 오는 부분은 반드시 DispatchQueue.main.async안에 넣어줘야한다
    아니면 보라색 경고창이 뜬다!
TextEditor(text: $reviewText)
                            .introspect(.textEditor, on: .iOS(.v15, .v16, .v17), customize: { uiTextView in
                                DispatchQueue.main.async {
                                    self.uiTextView = uiTextView
                                }
                            })
  1. textView(TextEditor)의 내용이 변경될 때마다 커서위치를 알아 올 수 있도록 한다. 현재 textView의 커서가 어디있는지 알아내고 그걸 View가 알 수 있도록 @StatecursorPosition 변수를 만들어준다(textView의 텍스트들이 변경될때마다 해당 변경을 감지해야 하므로 onChange를 이용해야 한다)
.onChange(of: reviewText) { newValue in
                                if let textView = uiTextView {
                                    if let range = textView.selectedTextRange {
                                        let cursorPosition = textView.offset(from: textView.beginningOfDocument, to: range.start)
                                        
                                        self.cursorPosition = cursorPosition
                                    }
                                }
                            }

cf) selectedTextRange란
문서 내에서 선택한 텍스트의 범위이다.
문서가 길이가 있으면 그게 현재 선택된 텍스트이고 텍스트가 nil이면 선택된 문서가 없는 것을 뜻한다
-> 즉, 커서의 위치를 가지고 오는 코드이다

cf) offset(from:to:)란
한 텍스트 위치와 다른 텍스트 위치 사이의 UTF-16 문자 수를 반환한다

cf) textView.beginningOfDocument란?
텍스트의 시작 위치를 가져온다

cf) range.start
텍스트 특정 범위의 시작위치를 의미한다(여기서는 특정 범위가 선택되지 않았으니까 커서의 위치라고 할 수 있다)

  1. textView의 커서가 변경될때마다 타는 cursorposition을 변경을 감지하고 이를 반영해서 scrollView의 scroll 위치를 변경해야 하므로 해당 scroll의 id를 지정해줘야 한다.(Namespace 이용)
.onChange(of: cursorPosition) { pos in
                proxy.scrollTo(cursorPositionId)
            }
  1. 최소 크기가 있는 경우 최소 크기를 정해주고(minHeight이용) text높이에 따라 textEditor가 늘어날 수 있도록 fixedSize를 적용해준다

최종 코드

@Namespace var cursorPositionId
    
    @State private var reviewText: String = ""
    @State private var uiTextView: UITextView?
    @State private var cursorPosition: Int = 0
    
    var body: some View {
        ScrollViewReader { proxy in
            ScrollView {
                TextEditor(text: $reviewText)
                    .introspect(.textEditor, on: .iOS(.v15, .v16, .v17), customize: { uiTextView in
                        DispatchQueue.main.async {
                            self.uiTextView = uiTextView
                        }
                    })
                    .onChange(of: reviewText) { newValue in
                        if let textView = uiTextView {
                            if let range = textView.selectedTextRange {
                                let cursorPosition = textView.offset(from: textView.beginningOfDocument, to: range.start)
                                
                                self.cursorPosition = cursorPosition
                            }
                        }
                    }
                    .id(cursorPositionId)
                    .font(.callout)
                    .frame(minHeight: 246, alignment: .topLeading)
                    .background(Color.gray)
                    .fixedSize(horizontal: false, vertical: true)
            }
            .onChange(of: cursorPosition) { pos in
                proxy.scrollTo(cursorPositionId)
            }
        }
        .padding()
    }

마무리

해당 코드랑 비슷하게 해서 프로젝트를 출시했는데 아이폰 15프로와 아이폰 14프로의 경우 특정 핸드폰에서 텍스트를 엄청나게 길게 쓰면 에러가 있는 것 같았다ㅠㅠ
그냥 uiKit의 텍스트를 가지고 와서 쓰는게 좋은 방법일 수도 있다ㅠ
ㅠㅠㅠ 아니면 다른 방법이 있는지 찾아봐야 할 것 같다,,,,
해당 코드 깃허브

참고 사이트

https://stackoverflow.com/questions/70211903/scrolling-to-texteditor-cursor-position-in-swiftui

profile
ios 개발을 하고있는 라무의 사적인 기술 블로그

0개의 댓글