/LinkedLabel

다양한 상황에서 UILabel에 링크를 달아보자(without. TTTAttributedLabel)

Primary LanguageSwift

블로그 원글 링크

다양한 상황에서 UILabel에 링크를 달아보자(without. TTTAttributedLabel)

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

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

하지만 2016년 이후로는 릴리즈가 없는 등 더 이상의 업데이트가 이루어지지 않고 있고, 전체 프로젝트에서 해당 라이브러리는 상당히 작은 부분에만 쓰이고 있었습니다. 그리고 현재 프로젝트 내에 많은 라이브러리가 사용되고 있는데 버전 업데이트가 이루어지면서 변경사항을 팔로업 하는 것에 리소스가 많이 소요되는 문제가 있었습니다. 그래서 이러한 이유들로 TTTAttributedLabel 라이브러리를 걷어내기로 결정했습니다.

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

  1. 평문과 링크 문자열의 스타일을 다르게 적용이 되어야 함
  2. 고정된 안내 문자열처럼 링크 URL이 고정된 경우
  3. 채팅 메시지처럼 한 문자열 내에 여러 URL이 달릴 수 있어야 하고 전부 개별적인 링크로 연결되어야 함

1번과 2번이 결합된 경우와 1번과 3번이 결합된 경우를 상정해 어떤식으로 구현할 수 있는지를 알아보겠습니다.

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

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

private var fixedLabel: UILabel = {
  let view = UILabel()
  view.numberOfLines = 0
  view.textAlignment = .center
  view.translatesAutoresizingMaskIntoConstraints = false
  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

  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()
  mutableString.append(
    NSAttributedString(string: generalText,attributes: generalAttributes)
  )
  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를 붙여서 눌린 부분이 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 }

        guard let subRange = text.range(of: subText) else { return nil }
        let range = NSRange(subRange, in: text)

        let layoutManager = NSLayoutManager()
        let textStorage = NSTextStorage(attributedString: attributedText)
        textStorage.addLayoutManager(layoutManager)

        let textContainer = NSTextContainer(size: intrinsicContentSize)
        textContainer.lineFragmentPadding = 0.0
        layoutManager.addTextContainer(textContainer)

        var glyphRange = NSRange()
        layoutManager.characterRange(forGlyphRange: range, actualGlyphRange: &glyphRange)

        return layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
    }
}

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

private lazy var fixedLabel: UILabel = {
  let view = UILabel()
  view.numberOfLines = 0
  view.textAlignment = .center
  view.translatesAutoresizingMaskIntoConstraints = false
  view.isUserInteractionEnabled = true

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

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

@objc func fixedLabelTapped(_ sender: UITapGestureRecognizer) {
        let point = sender.location(in: fixedLabel)
       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")
        }
    }

private 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를 만듭니다.

    private lazy var dynamicLabel: UILabel = {
        let view = UILabel()
        view.numberOfLines = 0
        view.isUserInteractionEnabled = true
        view.textAlignment = .center
        view.translatesAutoresizingMaskIntoConstraints = false

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

    private let button: UIButton = {
        let view = UIButton()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.backgroundColor = .systemBlue
        view.setTitle("전송", for: .normal)
        view.addTarget(self, action: #selector(sendButtondTapped(_:)), for: .touchUpInside)
        return view
    }()

    private lazy var textField: UITextField = {
        let view = UITextField()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.borderStyle = .roundedRect
        return view
    }()

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

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

채팅 메시지처럼 다양한 문자열에 담겨있는 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)

        let paragraph = NSMutableParagraphStyle()
        if let paragraphStyle = textStorage.attribute(
            .paragraphStyle, at: 0, effectiveRange: nil
        ) as? NSParagraphStyle {
            paragraph.setParagraphStyle(paragraphStyle)
        }
        paragraph.alignment = textAlignment
				textStorage.addAttribute(
            .paragraphStyle,
            value: paragraph,
            range: NSRange(location: 0, length: textStorage.length)
        )
        textStorage.addLayoutManager(layoutManager)
        textContainer.lineFragmentPadding = 0.0
        layoutManager.addTextContainer(textContainer)

        let range = layoutManager.glyphRange(for: textContainer)

        var textOffset = CGPoint.zero
        let textBounds = layoutManager.boundingRect(forGlyphRange: range, in: textContainer)
        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)

        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 타입인 변수 murl이 담긴 경우 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