ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • UITextView를 SwiftUI에서 커스텀하게 사용하기
    SwiftUI 2023. 2. 17. 14:00

    안녕하세요. 그린입니다🍏

    이번 포스팅에서는 SwiftUI에서 텍스트뷰 구현을 위해 기본 제공하는 TextEditor를 사용하지 않고 UIKit 세상의 UITextView를 가져와 입맛대로 텍스트뷰를 커스텀하게 구현해보도록 하겠습니다🙌

     

    왜 TextEditor 안쓰죠!?

    우선 현재 기준 iOS 16에서 TextEditor가 분명 쓸만해진것은 사실입니다.

    다만 최신 버전을 사용할 수 없는 환경에 처한 여러분들이 더 많을것 같고 저 역시 그렇습니다😭

    14,15 버전 기준으로 간단히 짚어보면 우선 SwiftUI의 기본 제공되는 TextEditor에서는 우리가 흔히 사용하는 placeholder를 기본적으로 제공하지 않아 필요하면 overlay로 컨트롤 해줘야합니다.

    또 텍스트가 입력되는 칸의 Inset을 조정할때도 모두 커스텀하게 구현해야합니다.

    또..... 가변적인 높이를 위해서도 별도 작업을 더 거쳐야합니다.

     

    즉 정리해보면 SwiftUI의 기본 TextEditor로 구현을 할 순 있다!
    하지만 공수가 많이 들고 뷰 코드가 오히려 더 복잡해질 수 있다🥲

     

    그래서 이걸 해결해보고자 우리가 익숙한 UIKit의 UITextView를 끌어들여와 작업 해보겠습니다.

     

    UITextView in SwiftUI

    자 제가 필요한 구현은 아래와 같습니다.

    1. 텍스트 라인 수에 따라 가변적인 텍스트 인풋창 높이 조절

    2. 플레이스홀더 구현

    3. 화면 진입 시 자동으로 키보드 노출 및 텍스트 필드 인풋 영역이 해당 키보드 위에 위치하기

    4. 글자 수 제한

    5. 그 외 디자인적 요소 커스텀하게 구현

     

    이거 해보려합니다!

    그럼 해당 커스텀 TextView 코드부터 보시죠!

    import SwiftUI
    
    public struct CustomTextView: UIViewRepresentable {
      @Binding var text: String
      @Binding var height: CGFloat
      var maxHeight: CGFloat
      var textFont: UIFont
      var textColor: UIColor = .black
      var textLimit: Int = 10
      var cornerRadius: CGFloat? = nil
      var borderWidth: CGFloat? = nil
      var borderColor: CGColor? = nil
      var isScrollEnabled: Bool = true
      var isEditable: Bool = true
      var isUserInteractionEnabled: Bool = true
      var lineFragmentPadding: CGFloat = 0
      var textContainerInset: UIEdgeInsets = .init(top: 10, left: 10, bottom: 10, right: 10)
      var placeholder: String? = nil
      var placeholderColor: UIColor = .gray
      
      public func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()
        
        if let cornerRadius = cornerRadius {
          textView.layer.cornerRadius = cornerRadius
          textView.layer.masksToBounds = true
        }
        if let borderWidth = borderWidth {
          textView.layer.borderWidth = borderWidth
        }
        if let borderColor = borderColor {
          textView.layer.borderColor = borderColor
        }
        if let placeholder = placeholder {
          textView.text = placeholder
          textView.textColor = placeholderColor
        } else {
          textView.textColor = textColor
        }
        
        textView.font = textFont
        textView.isScrollEnabled = isScrollEnabled
        textView.isEditable = isEditable
        textView.isUserInteractionEnabled = isUserInteractionEnabled
        textView.textContainer.lineFragmentPadding = lineFragmentPadding
        textView.textContainerInset = textContainerInset
        textView.delegate = context.coordinator
        textView.becomeFirstResponder()
        
        return textView
      }
      
      public func updateUIView(_ uiView: UITextView, context: Context) {
        updateHeight(uiView)
      }
      
      public func makeCoordinator() -> Coordinator {
        return Coordinator(parent: self)
      }
      
      private func updateHeight(_ uiView: UITextView) {
        let size = uiView.sizeThatFits(CGSize(width: uiView.frame.width, height: .infinity))
        DispatchQueue.main.async {
          if size.height <= maxHeight {
            height = size.height
          }
        }
      }
      
      public class Coordinator: NSObject, UITextViewDelegate {
        var parent: CustomTextView
        
        init(parent: CustomTextView) {
          self.parent = parent
        }
        
        public func textViewDidChange(_ textView: UITextView) {
          parent.text = textView.text
          
          if textView.text.isEmpty {
            textView.textColor = parent.placeholderColor
          } else {
            textView.textColor = parent.textColor
          }
          
          if textView.text.count > parent.textLimit {
            textView.text.removeLast()
          }
          
          parent.updateHeight(textView)
        }
        
        public func textViewDidBeginEditing(_ textView: UITextView) {
          if textView.text == parent.placeholder {
            textView.text = ""
          }
        }
        
        public func textViewDidEndEditing(_ textView: UITextView) {
          if textView.text.isEmpty {
            textView.text = parent.placeholder
          }
        }
      }
    }

    으악 좀 길긴한데... 커스텀한거니까⭐️

    일단 UIViewRepresentable로 UIKit 세상에 있는 UITextView를 가져와줍니다.

    각 파라미터로 들어갈 인자들은 생략하고~

    updateHeight 메서드를 보시죠!

    해당 메서드를 보시면 현재 UIView의 사이즈를 판단하며 maxHeight까지만 리밋을 걸며 가변 변환을 해줍니다.

    그리고 updateUIView에서 해당 메서드를 호출해 계속 판단하죠.

    그 다음 UITextViewDelegate의 기능을 가져와 필요한 구현을 해줍니다.

    여기서 필요한 구현은 플레이스홀더 구분 및 글자 수 입력 제한 등과 같은 다양한 기능들을 넣어줄 수 있어요.

     

    자 나머지는 거의 디자인적인 커스텀이니 패스하고 실제 해당 뷰를 얹어볼까요?

     

    import SwiftUI
    
    struct ContentView: View {
      var body: some View {
        VStack(alignment: .leading, spacing: 10) {
          Spacer()
            .frame(height: 100)
          
          Button(
            action: {
              UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
            },
            label: {
              Text("Hide Keyboard")
            }
          )
          
          Spacer()
          
          TextWritingView()
            .background(.gray)
        }
      }
    }
    
    private struct TextWritingView: View {
      @State private var height: CGFloat = 30
      @State var text: String = ""
      private var registerButtonDisabled: Bool {
        text.isEmpty
      }
      private var registerButtonTextColor: Color {
        text.isEmpty ? .red : .green
      }
      
      var body: some View {
        HStack(alignment: .bottom, spacing: 10) {
          // 댓글 입력창
          CustomTextView(
            text: $text,
            height: $height,
            maxHeight: 200,
            textFont: .boldSystemFont(ofSize: 14),
            cornerRadius: 5,
            borderWidth: 1,
            borderColor: CGColor.init(red: 255, green: 255, blue: 255, alpha: 1),
            placeholder: "댓글을 입력해 주세요"
          )
          .frame(minHeight: height, maxHeight: .infinity)
          
          // 등록 버튼
          Button(
            action: {
              UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
            },
            label: {
              Text("등록")
                .foregroundColor(registerButtonTextColor)
            }
          )
          .disabled(registerButtonDisabled)
          .padding(.bottom, 5)
        }
        .padding(.all, 10)
        .frame(height: 50)
        .frame(minHeight: height + 20)
      }
    }

    먼저 봐야할건 TextWritingView입니다.

    커스텀한 TextView와 height를 공유하도록 @State 변수를 초기 높이를 주어 지정하고 text도 마찬가지로 지정합니다.

    그 후 frame 작업만 잘 컨트롤 해주면 됩니다.

    TextField의 최소 높이는 height이고 최대 높이에 대해서는 커스텀하게 구현한 텍스트뷰 내부에서 컨트롤 됩니다.

    등록 버튼이나 특정한 onTapGesture를 했을때 키보드를 숨겨줄 수 있도록 구현해줍니다.

     

    이러면 사실 끝이라 크게 설명보다 코드 보고 이해하는게 빠를것 같긴 하네요🥲

     

    시연 영상

     

    마무리

    그냥 생활 속 발견 같은 느낌으로 가볍게 구현해보면서 공유해봐요🙌

     

    [참고 자료]

    제 아래 깃헙 레포에 위 예제 소스코드를 올려놨습니다.

    https://github.com/GREENOVER/playground/tree/main/customTextView

     

    GitHub - GREENOVER/playground: 학습을 하며 간단한 예제들을 만들어 보는 작고 소중한 놀이터

    학습을 하며 간단한 예제들을 만들어 보는 작고 소중한 놀이터. Contribute to GREENOVER/playground development by creating an account on GitHub.

    github.com

    'SwiftUI' 카테고리의 다른 글

    AVPlayer in SwiftUI (feat. PIP)  (6) 2023.03.23
    UIKit과 SwiftUI에서 텍스트의 자간&행간 조절하기  (8) 2023.02.20
    SwiftUI - View를 구조체로 생성하는 이유  (7) 2022.12.26
    SwiftUI - View  (3) 2022.12.24
    SwiftUI - refreshable  (0) 2022.12.08
Designed by Tistory.