앱 개발을 하다 보면 다양한 환경에서 텍스트와 링크를 연결해줘야 하는 상황이 생기게 됩니다. 내용이 정해져 있는 문자열이라면 특정 부분만 UIButton
등으로 구현하는 방법을 사용할 수도 있습니다. 하지만 어떤 내용이 작성될 지 모르는 채팅창 내용에 URL 링크를 활성화해야 하는 경우라면 다른 방법이 필요할 것 같습니다.
다양한 상황에서 UILabel
에 링크를 연결하기 위해, 기존 프로젝트에서 TTTAttributedLabel
이라는 써드파티 라이브러리를 사용하고 있었습니다. 사실 해당 라이브러리의 역할이 텍스트에 링크만 달아주는 것 뿐만은 아닙니다. 자동으로 URL, 주소, 전화번호 등의 데이터를 감지할 수도 있고, 한 UILabel
안에 다양한 스타일을 적용할 수도 있습니다.
하지만 2016년 이후로는 릴리즈가 없는 등 더 이상의 업데이트가 이루어지지 않고 있고, 전체 프로젝트에서 해당 라이브러리는 상당히 작은 부분에만 쓰이고 있었습니다. 그래서 TTTAttributedLabel
라이브러리를 걷어내기로 결정했습니다.
해당 라이브러리를 제거하기로 결정한 이상 몇 가지 고려 할 사항이 있습니다.
- 평문과 링크 문자열의 스타일이 다르게 적용 필요
- 고정된 문자열에 고정된 URL이 달리는 상황
- 고정되지 않은 문자열에 고정되지 않은 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
색상은 파란 링크 컬러로 덮어씌워지게 됩니다. 명확한 디자인 요구사항이 있는 경우에는 유효한 선택지가 될 수 없겠군요.
그럼 UILabel
에 UITapGestrueReconginzer
를 붙여서 눌린 부분의 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
)
}
}
아까 생성한 fixedLabel
에 isUserInteractionEnabeld
옵션을 켜주고 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되었을 때 제스쳐가 감지한 UILabel
의 CGPoint
에 해당 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을 할당합니다. 그리고 앞서 선언된 mutableString
에 attributes
를 지정합니다. 그럼 아래처럼 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?