다양한 상황에서 UILabel에 링크를 달아보자

근데 이제 써드파티 라이브러리 없이

·

7 min read

앱 개발을 하다 보면 다양한 환경에서 텍스트와 링크를 연결해줘야 하는 상황이 생기게 됩니다. 내용이 정해져 있는 문자열이라면 특정 부분만 UIButton등으로 구현하는 방법을 사용할 수도 있습니다. 하지만 어떤 내용이 작성될 지 모르는 채팅창 내용에 URL 링크를 활성화해야 하는 경우라면 다른 방법이 필요할 것 같습니다.

다양한 상황에서 UILabel에 링크를 연결하기 위해, 기존 프로젝트에서 TTTAttributedLabel이라는 써드파티 라이브러리를 사용하고 있었습니다. 사실 해당 라이브러리의 역할이 텍스트에 링크만 달아주는 것 뿐만은 아닙니다. 자동으로 URL, 주소, 전화번호 등의 데이터를 감지할 수도 있고, 한 UILabel 안에 다양한 스타일을 적용할 수도 있습니다.

하지만 2016년 이후로는 릴리즈가 없는 등 더 이상의 업데이트가 이루어지지 않고 있고, 전체 프로젝트에서 해당 라이브러리는 상당히 작은 부분에만 쓰이고 있었습니다. 그래서 TTTAttributedLabel 라이브러리를 걷어내기로 결정했습니다.

해당 라이브러리를 제거하기로 결정한 이상 몇 가지 고려 할 사항이 있습니다.

  1. 평문과 링크 문자열의 스타일이 다르게 적용 필요
  2. 고정된 문자열에 고정된 URL이 달리는 상황
  3. 고정되지 않은 문자열에 고정되지 않은 URL이 달리는 상황(ex. 채팅 메세지 같은)

평문과 링크 문자열의 스타일을 다르게 적용한다는 전제하에 2번의 경우와 3번의 경우를 어떻게 구현할 수 있는지를 알아보겠습니다.


고정된 문자열에 고정된 링크 URL + 스타일 적용

우선 문자열을 표현할 UILabel을 선언하고 속성들을 지정합니다.

var fixedLabel: UILabel = {
  let view = UILabel()
  view.numberOfLines = 0
  return view
}()

하나의 문자열에 여러 스타일을 적용하는 것은 NSAttributedString을 이용하면 쉽게 할 수 있습니다.

아래와 같이 google과 github 부분에만 이탤릭폰트, 초록색, 언더라인을 지정해줍니다.

func configureLabel() {
  let google = "google"
  let github = "github"
  let generalText = String(
    format: "고정된 링크로 이동하는 예제로 \n%@링크와 %@링크로 이동해봅시다",
    google,
    github
  )
  let italicFont = UIFont.italicSystemFont(ofSize: 18)
  let boldFont = UIFont.boldSystemFont(ofSize: 18)
  let green = UIColor.systemGreen
  let darkGray = UIColor.darkGray

  // NSAttributedString.Key, Value 속성 정의
  let generalAttributes: [NSAttributedString.Key: Any] = [
    .foregroundColor:darkGray,
    .font: boldFont
  ]

  let linkAttributes: [NSAttributedString.Key: Any] = [
    .underlineStyle: NSUnderlineStyle.single.rawValue,
    .foregroundColor: green,
    .font: italicFont
  ]
  let mutableString = NSMutableAttributedString()

  // generalAttributes(기본 스타일) 적용
  mutableString.append(
    NSAttributedString(string: generalText,attributes: generalAttributes)
  )

  // 각 문자열의 range에 linkAttributes 적용  
  mutableString.setAttributes(
    linkAttributes,
    range: (generalText as NSString).range(of: google)
  )

  mutableString.setAttributes(
    linkAttributes,
    range: (generalText as NSString).range(of: github)
  )

  fixedLabel.attributedText = mutableString
}

그럼 이미지와 같이 스타일이 적용된 UILabel을 볼 수 있습니다.

그렇지만 현재는 스타일만 적용된 상태로, 라벨의 google과 github 부분을 눌러도 아무런 일도 일어나지 않습니다.

링크를 적용하는 방법으로 가장 먼저는 NSAttributedString의 속성에 .link 키를 이용하는 방법입니다. 하지만 그렇게 하게 되면 기존에 주었던 UIColor.systemGreen색상은 파란 링크 컬러로 덮어씌워지게 됩니다. 명확한 디자인 요구사항이 있는 경우에는 유효한 선택지가 될 수 없겠군요.

그럼 UILabelUITapGestrueReconginzer를 붙여서 눌린 부분의 CGPoint가 문자열의 'google' 부분인지 'github' 부분인지에 따라서 링크를 띄워주는 방법은 어떨까요?

일단 그러기 위해 UILabel 내 특정 문자열의 CGRect를 반환하는 메서드를 구현합니다.

extension UILabel {
    /// 라벨 내 특정 문자열의 CGRect 반환
    /// - Parameter subText: CGRect값을 알고 싶은 특정 문자열
    func boundingRectForCharacterRange(subText: String) -> CGRect? {
        guard let attributedText = attributedText else { return nil }
        guard let text = self.text else { return nil }

        // 전체 텍스트(text)에서 subText만큼의 range를 구합니다.
        guard let subRange = text.range(of: subText) else { return nil }
        let range = NSRange(subRange, in: text)

        // attributedText를 기반으로 한 NSTextStorage를 선언하고 NSLayoutManager를 추가합니다.
        let layoutManager = NSLayoutManager()
        let textStorage = NSTextStorage(attributedString: attributedText)
        textStorage.addLayoutManager(layoutManager)

        // instrinsicContentSize를 기반으로 NSTextContainer를 선언하고
        let textContainer = NSTextContainer(size: intrinsicContentSize)

        // 정확한 CGRect를 구해야하므로 padding 값은 0을 줍니다.
        textContainer.lineFragmentPadding = 0.0

        // layoutManager에 추가합니다.
        layoutManager.addTextContainer(textContainer)
        var glyphRange = NSRange()

        // 주어진 범위(rage)에 대한 실질적인 glyphRange를 구합니다.
        layoutManager.characterRange(
            forGlyphRange: range,
            actualGlyphRange: &glyphRange
        )

        // textContainer 내의 지정된 glyphRange에 대한 CGRect 값을 반환합니다.
        return layoutManager.boundingRect(
            forGlyphRange: glyphRange,
            in: textContainer
        )
    }
}

아까 생성한 fixedLabelisUserInteractionEnabeld옵션을 켜주고 UITapGestrueReconginzer를 추가해줍니다.

var fixedLabel: UILabel = {
  let view = UILabel()
  view.numberOfLines = 0
  view.isUserInteractionEnabled = true

  let recognizer = UITapGestureRecognizer(
    target: self,
    action: #selector(fixedLabelTapped(_:))
  )
  view.addGestureRecognizer(recognizer)
  return view
}()

그리고 fixedLabelTapped(_:) 메소드도 선언합니다.

@objc func fixedLabelTapped(_ sender: UITapGestureRecognizer) {
    //fixedLabel에서 UITapGestureRecognizer로 선택된 부분의 CGPoint를 구합니다.
    let point = sender.location(in: fixedLabel)

    // fixedLabel 내에서 문자열 google이 차지하는 CGRect값을 구해, 그 안에 point가 포함되는지를 판단합니다.
    if let googleRect = fixedLabel.boundingRectForCharacterRange(subText: "google"),
                        googleRect.contains(point) {
        present(url: "https://www.google.com")
    }

    if let githubRect = fixedLabel.boundingRectForCharacterRange(subText: "github"),
                        githubRect.contains(point) {
        present(url: "https://www.github.com")
    }
}

func present(url string: String) {
  if let url = URL(string: string) {
    let viewController = SFSafariViewController(url: url)
    present(viewController, animated: true)
  }
}

그럼 아래처럼 정해진 곳으로 잘 이동하는 것을 볼 수 있습니다.

이처럼 정해진 곳으로만 보내주는 고정된 문자열, URL이라면 이와 같은 방법이 해결책이 될 수 있습니다. 하지만 앞서 말한 것처럼 채팅방의 메시지처럼 불특정 URL 주소를 링킹 해줘야 하는 경우라면 어떻게 구현할 수 있을까요?




고정되지 않은 문자열에 고정되지 않은 URL + 스타일 적용

먼저는 UILabel, UITextField, UIButton을 이용해 채팅창과 비슷한 UI를 만들어줍니다.

var dynamicLabel: UILabel = {
    let view = UILabel()
    view.numberOfLines = 0
    view.isUserInteractionEnabled = true
    view.alignment = .left

    let recognizer = UITapGestureRecognizer(
        target: self,
        action: #selector(dynamicLabelTapped(_:))
    )

    view.addGestureRecognizer(recognizer)
    return view
}()

var button: UIButton = {
    let view = UIButton()
    view.backgroundColor = .systemBlue
    view.setTitle("전송", for: .normal)

    view.addTarget(
        self,
        action: #selector(sendButtondTapped(_:)),
        for: .touchUpInside
    )

    return view
}()

var textField: UITextField = {
    let view = UITextField()
    view.borderStyle = .roundedRect
    return view
}()

그리고 버튼이 눌렸을 때 텍스트 필드를 비워주고 라벨에 문자열을 채워 넣도록 합니다.

 @objc func sendButtondTapped(_ sender: UIButton) {
     dynamicLabel.text = textField.text
     textField.text = ""
}

그럼 아래와 같은 UI가 표시됩니다.

채팅 메시지처럼 다양한 문자열에 담겨있는 URL에 링크를 달기 위해서 NSAttributedString.Key.attachment 키를 사용했습니다.

.attachment 키에 URL을 담고, 라벨이 tapped되었을 때 제스쳐가 감지한 UILabelCGPoint에 해당 attribute가 담겨있는지 확인하는 방법입니다. 그렇게 하면 어떤 문자열이던 URL인 경우라면 해당 URL로 링크를 걸어줄 수 있습니다.

개별 문자열 스타일을 적용하기 위해서 이미 NSAttributedString을 사용하고 있었기에 금세 추가적인 attribute를 설정할 수 있습니다. 그리고 UITapGestureRecognizer를 이용해서 UILabel 중 tapped된 CGPoint를 알아내는 것 또한 가능합니다. 하지만 입력된 포지션에 따라 라벨의 문자열의 인덱스를 반환하는 함수가 필요했습니다.

여러 번의 시행착오 끝에 아래와 같은 함수를 구현했습니다.

extension UILabel {
  /// 입력된 포지션에 따라 라벨의 문자열의 인덱스 반환
  /// - Parameter point: 인덱스 값을 알고 싶은 CGPoint
  func textIndex(at point: CGPoint) -> Int? {
      guard let attributedText = attributedText else { return nil }

      let layoutManager = NSLayoutManager()
      let textContainer = NSTextContainer(size: self.bounds.size)
      let textStorage = NSTextStorage(attributedString: attributedText)      

      textStorage.addLayoutManager(layoutManager)
      textContainer.lineFragmentPadding = 0.0
      layoutManager.addTextContainer(textContainer)
      var textOffset = CGPoint.zero

      // 정확한 자체(glyph)의 범위를 구하고 그 범위의 CGRect 값을 구합니다.
      let range = layoutManager.glyphRange(for: textContainer)
      let textBounds = layoutManager.boundingRect(
          forGlyphRange: range,
          in: textContainer
      )

      // textOffset.x가 패딩을 제외한 부분부터 시작하도록 합니다.
      let paddingWidth = (self.bounds.size.width - textBounds.size.width) / 2
      if paddingWidth > 0 {
          textOffset.x = paddingWidth
      }

      // 눌려진 정확한 포인트를 구합니다.
      let newPoint = CGPoint(
          x: point.x - textOffset.x,
          y: point.y - textOffset.y
      )
      // textContainer내에서 newPoint 위치의 glyph index를 반환합니다
      return layoutManager.glyphIndex(for: newPoint, in: textContainer)
  }
}

그리고 UITapGestureRecognizer를 이용해 터치된 포지션을 확인하기 이전에 UILabel에 스타일과 관련한 속성과 입력된 문자열이 URL인지 확인해 attatchment에 URL을 담아주는 코드를 작성합니다.

private func configureLabel() {
  guard let messageText = dynamicLabel.text else { return }
  let mutableString = NSMutableAttributedString()

  let normalAttributes: [NSMutableAttributedString.Key: Any] = [
    .foregroundColor: UIColor.darkGray,
    .font: UIFont.boldSystemFont(ofSize: 18)
  ]
  var urlAttributes: [NSMutableAttributedString.Key: Any] = [
    .foregroundColor: UIColor.systemGreen,
    .underlineStyle: NSUnderlineStyle.single.rawValue,
    .font: UIFont.italicSystemFont(ofSize: 18)
  ]

  let normalText = NSAttributedString(string: messageText, attributes: normalAttributes)
  mutableString.append(normalText)

  do {
    let detector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
    let matches = detector.matches(
      in: messageText,
      options: [],
      range: NSRange(location: 0, length: messageText.count)
    )

    for m in matches {
      if let url = m.url {
        urlAttributes[.attachment] = url
        mutableString.setAttributes(urlAttributes, range: m.range)
      }
    }
    dynamicLabel.attributedText = mutableString
  } catch {
    print(error)
  }
}

문자열에 URL이 담겨있는지 여부는 NSRegularExpression의 서브클래스인 NSDataDetector로 판단합니다.

NSTextCheckingResult 타입인 변수 m에 url이 담긴 경우 urlAttributes[.attatchment]에 url을 할당합니다. 그리고 앞서 선언된 mutableStringattributes를 지정합니다. 그럼 아래처럼 URL인 부분과 그렇지 않은 부분에 구분되어 스타일이 적용됩니다.

하지만 지금은 링크를 눌러도 아무런 변화가 일어나지 않습니다.

이제는 아까 만들어둔 CGPoint를 반환하는 함수를 이용할 때 입니다.

@objc func dynamicLabelTapped(_ sender: UITapGestureRecognizer) {
  let point = sender.location(in: dynamicLabel)
  guard let selectedIndex = dynamicLabel.textIndex(at: point) else { return }
  guard let attr = dynamicLabel.attributedText?.attributes(
      at: selectedIndex,
      effectiveRange: nil
  ),
  let url = attr[.attachment] as? URL
  else { return }
  present(url: url.absoluteString)
}

textIndex(at:) 메서드를 이용해 position을 기반으로 터치된 부분의 라벨의 인덱스를 가져옵니다. 그럼 dynamicLabel의 속성들에 .attachment 속성이 담겨있고 URL 타입인 경우 웹 화면을 띄워주도록 합니다.

그럼 위처럼 고정되지 않은 문자열에 스타일 적용 + 링크 띄워주기가 가능해집니다 😃

만들면서 이미 있는 바퀴를 재발명할 필요가 있을까? 라는 생각도 잠깐 들었지만 글 서론에 이야기했던 것처럼 관리되지 않는 라이브러리에 의존성도 덜어내고 어떻게 구현할 지 고민하고 공부 할 겸 나름 즐거운 마음으로 했던 작업이었습니다.

프로젝트 전체 코드는 여기 레포지토리에서 확인 가능합니다.

References

StackOverflow - How do I locate the CGRect for a substring of text in a UILabel?