- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态 - 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试 - 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证) - 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互 - 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache) - 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy - 更新 epub-bridge.js 与 JS bridge 通信协议 - 全面更新现有 UI 测试以适配新的 helper 和状态管理
548 lines
21 KiB
Swift
548 lines
21 KiB
Swift
// RDEPUBReaderController+ContentDelegates.swift
|
||
// EPUB 阅读器内容视图代理实现
|
||
// 处理 Web 渲染路径(RDEPUBWebContentViewDelegate)和 Native Text 渲染路径
|
||
// (RDEPUBTextContentViewDelegate)的位置更新、选区变化、链接跳转、错误日志等事件回调。
|
||
|
||
import UIKit
|
||
|
||
/// RDEPUBReaderController 内容代理扩展
|
||
///
|
||
/// 本文件实现 EPUB 阅读器的内容视图代理,处理 Web 渲染路径和 Native Text 渲染路径的
|
||
/// 位置更新、选区变化、链接跳转、错误日志等事件回调。
|
||
|
||
// MARK: - Web 内容视图代理(EPUB 固定布局/Web 渲染路径)
|
||
|
||
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||
/// Web 内容视图位置更新回调,同步阅读上下文与持久化位置
|
||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
|
||
guard readerView.currentPage >= 0,
|
||
activePages.indices.contains(readerView.currentPage) else {
|
||
return
|
||
}
|
||
|
||
let currentPage = activePages[readerView.currentPage]
|
||
guard readingSession?.pageContains(spineIndex: spineIndex, in: currentPage) == true else {
|
||
return
|
||
}
|
||
|
||
persist(location: location)
|
||
readingSession?.updateReadingContext(
|
||
pageNumber: readerView.currentPage + 1,
|
||
location: location,
|
||
spineIndex: spineIndex,
|
||
chapterIndex: currentPage.chapterIndex,
|
||
bookIdentifier: currentBookIdentifier
|
||
)
|
||
}
|
||
|
||
/// Web 内容视图选区变化回调,转换为统一选区模型
|
||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
|
||
if let selection {
|
||
updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex))
|
||
} else {
|
||
updateCurrentSelection(nil)
|
||
}
|
||
}
|
||
|
||
/// Web 内容视图选区菜单操作回调
|
||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
|
||
handleSelectionMenuAction(action, selection: currentSelection)
|
||
}
|
||
|
||
/// Web 内容视图内部链接点击回调,执行页内跳转
|
||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
|
||
guard let readingSession,
|
||
let pageNumber = readingSession.queueNavigation(
|
||
to: location,
|
||
relativeToSpineIndex: fromSpineIndex,
|
||
bookIdentifier: currentBookIdentifier
|
||
) else {
|
||
return
|
||
}
|
||
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
|
||
}
|
||
|
||
/// Web 内容视图外部链接点击回调,根据配置策略决定是否打开
|
||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
|
||
delegate?.epubReader(self, didActivateExternalLink: url)
|
||
openExternalURLIfAllowed(url)
|
||
}
|
||
|
||
/// Web 内容视图 JavaScript 错误日志回调
|
||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
|
||
#if DEBUG
|
||
print("EPUB JS Error: \(message)")
|
||
#endif
|
||
}
|
||
}
|
||
|
||
// MARK: - 文本内容视图代理(Native Text 渲染路径)
|
||
|
||
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||
/// 文本内容视图选区变化回调,标准化后更新当前选区
|
||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
|
||
guard let selection else {
|
||
updateCurrentSelection(nil)
|
||
return
|
||
}
|
||
updateCurrentSelection(selection)
|
||
}
|
||
|
||
/// 文本内容视图选区菜单操作回调
|
||
func textContentView(
|
||
_ contentView: RDEPUBTextContentView,
|
||
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
||
selection: RDEPUBSelection?
|
||
) {
|
||
handleSelectionMenuAction(action, selection: selection ?? currentSelection)
|
||
contentView.clearSelection()
|
||
}
|
||
|
||
func textContentView(
|
||
_ contentView: RDEPUBTextContentView,
|
||
didActivateAttachmentText text: String,
|
||
sourceRect: CGRect
|
||
) {
|
||
presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect)
|
||
}
|
||
|
||
func textContentView(
|
||
_ contentView: RDEPUBTextContentView,
|
||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
||
sourceRect: CGRect
|
||
) {
|
||
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
|
||
}
|
||
|
||
|
||
/// 根据 EPUB 位置计算对应的页码
|
||
func pageNumber(for location: RDEPUBLocation) -> Int? {
|
||
if let publication,
|
||
let bookPageMap = readerContext.bookPageMap,
|
||
let spineIndex = readerContext.normalizedSpineIndex(for: location),
|
||
let entry = bookPageMap.entry(forSpineIndex: spineIndex) {
|
||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||
location,
|
||
relativeToSpineIndex: nil,
|
||
bookIdentifier: currentBookIdentifier
|
||
) ?? location
|
||
let localPageIndex: Int
|
||
if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) {
|
||
let offset = chapterOffset(for: normalizedLocation, fallbackEntry: entry)
|
||
localPageIndex = summary.pageRanges.firstIndex {
|
||
let range = $0.nsRange
|
||
return offset >= range.location && offset <= max(range.location + range.length - 1, range.location)
|
||
} ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
|
||
} else {
|
||
localPageIndex = fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
|
||
}
|
||
return bookPageMap.absolutePageIndex(
|
||
spineIndex: spineIndex,
|
||
localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0))
|
||
).map { $0 + 1 }
|
||
}
|
||
|
||
if let textBook, let publication {
|
||
if let anchor = location.rangeAnchor?.start {
|
||
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
|
||
return page + 1
|
||
}
|
||
}
|
||
|
||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||
location,
|
||
relativeToSpineIndex: nil,
|
||
bookIdentifier: currentBookIdentifier
|
||
) ?? location
|
||
return textBook.pageNumber(
|
||
for: normalizedLocation,
|
||
resolver: publication.resourceResolver,
|
||
bookIdentifier: currentBookIdentifier
|
||
)
|
||
}
|
||
|
||
return readingSession?.queueNavigation(
|
||
to: location,
|
||
relativeToSpineIndex: nil,
|
||
bookIdentifier: currentBookIdentifier
|
||
)
|
||
}
|
||
|
||
/// 根据页码解析对应的文本位置
|
||
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
|
||
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
|
||
let chapterLength = max(resolvedPage.chapter.typesetAttributedString.length, 1)
|
||
let startOffset = resolvedPage.page.pageStartOffset
|
||
let endOffset = max(startOffset, resolvedPage.page.pageEndOffset)
|
||
let fragmentID = nearestFragmentID(
|
||
beforeOrAt: startOffset,
|
||
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets
|
||
)
|
||
let location = RDEPUBLocation(
|
||
bookIdentifier: currentBookIdentifier,
|
||
href: resolvedPage.page.href,
|
||
progression: Double(startOffset) / Double(chapterLength),
|
||
lastProgression: Double(endOffset) / Double(chapterLength),
|
||
fragment: fragmentID,
|
||
rangeAnchor: RDEPUBTextRangeAnchor(
|
||
start: RDEPUBTextAnchor(
|
||
fileIndex: resolvedPage.page.spineIndex,
|
||
row: 0,
|
||
column: 0,
|
||
chapterOffset: startOffset,
|
||
fragmentID: fragmentID
|
||
),
|
||
end: RDEPUBTextAnchor(
|
||
fileIndex: resolvedPage.page.spineIndex,
|
||
row: 0,
|
||
column: 0,
|
||
chapterOffset: endOffset,
|
||
fragmentID: fragmentID
|
||
)
|
||
)
|
||
)
|
||
if let publication {
|
||
return publication.resourceResolver.normalizedLocation(
|
||
location,
|
||
relativeToSpineIndex: nil,
|
||
bookIdentifier: currentBookIdentifier
|
||
) ?? location
|
||
}
|
||
return location
|
||
}
|
||
|
||
guard let textBook,
|
||
let page = textBook.page(at: pageNumber) else {
|
||
return nil
|
||
}
|
||
|
||
let location = textBook.chapterData(forPageNumber: pageNumber)?.location(forPage: page, bookIdentifier: currentBookIdentifier)
|
||
?? textBook.location(forPageNumber: pageNumber, bookIdentifier: currentBookIdentifier)
|
||
guard let location else { return nil }
|
||
|
||
if let publication {
|
||
return publication.resourceResolver.normalizedLocation(
|
||
location,
|
||
relativeToSpineIndex: nil,
|
||
bookIdentifier: currentBookIdentifier
|
||
) ?? location
|
||
}
|
||
return location
|
||
}
|
||
|
||
/// 同步文本阅读状态到阅读会话(页码、位置、spine、章节等)
|
||
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
|
||
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
|
||
readingSession?.updateReadingContext(
|
||
pageNumber: pageNumber,
|
||
location: location,
|
||
spineIndex: resolvedPage.page.spineIndex,
|
||
chapterIndex: resolvedPage.chapterIndex,
|
||
bookIdentifier: currentBookIdentifier
|
||
)
|
||
return
|
||
}
|
||
|
||
guard let textBook,
|
||
let page = textBook.page(at: pageNumber) else {
|
||
readingSession?.transition(to: .idle)
|
||
return
|
||
}
|
||
|
||
readingSession?.updateReadingContext(
|
||
pageNumber: pageNumber,
|
||
location: location,
|
||
spineIndex: page.spineIndex,
|
||
chapterIndex: page.chapterIndex,
|
||
bookIdentifier: currentBookIdentifier
|
||
)
|
||
}
|
||
|
||
/// 从文本书籍生成原生文本快照(页面列表 + 章节信息)
|
||
func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot {
|
||
let chapters = textBook.chapterInfos
|
||
let pages = textBook.pages.map {
|
||
EPUBPage(
|
||
spineIndex: $0.spineIndex,
|
||
chapterIndex: $0.chapterIndex,
|
||
pageIndexInChapter: $0.pageIndexInChapter,
|
||
totalPagesInChapter: $0.totalPagesInChapter,
|
||
chapterTitle: $0.chapterTitle,
|
||
fixedSpread: nil
|
||
)
|
||
}
|
||
return (pages, chapters)
|
||
}
|
||
|
||
private func resolvedRuntimePage(forPageNumber pageNumber: Int) -> RDEPUBResolvedPage? {
|
||
runtime.pageResolver.resolvePage(absolutePageIndex: pageNumber - 1)
|
||
}
|
||
|
||
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int {
|
||
if let anchor = location.rangeAnchor?.start {
|
||
return anchor.chapterOffset
|
||
}
|
||
if let fragment = location.fragment,
|
||
let offset = fallbackEntry.fragmentOffsets[fragment] {
|
||
return offset
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func fallbackLocalPageIndex(for location: RDEPUBLocation, pageCount: Int) -> Int {
|
||
guard pageCount > 1 else { return 0 }
|
||
return min(
|
||
pageCount - 1,
|
||
max(0, Int(round(location.navigationProgression * Double(pageCount - 1))))
|
||
)
|
||
}
|
||
|
||
private func nearestFragmentID(beforeOrAt offset: Int, fragmentOffsets: [String: Int]) -> String? {
|
||
var bestID: String?
|
||
var bestOffset = Int.min
|
||
for (fragmentID, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset {
|
||
bestOffset = fragmentOffset
|
||
bestID = fragmentID
|
||
}
|
||
return bestID
|
||
}
|
||
|
||
// MARK: - 外部链接策略
|
||
|
||
private func shouldAllowExternalURL(_ url: URL) -> Bool {
|
||
guard let scheme = url.scheme?.lowercased() else { return false }
|
||
if delegate?.epubReader(self, shouldOpenExternalURL: url) == false {
|
||
return false
|
||
}
|
||
return configuration.allowedExternalURLSchemes.contains(scheme)
|
||
}
|
||
|
||
private func openExternalURLIfAllowed(_ url: URL) {
|
||
guard shouldAllowExternalURL(url) else { return }
|
||
if configuration.requiresExternalLinkConfirmation {
|
||
presentExternalLinkConfirmation(for: url)
|
||
} else {
|
||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||
}
|
||
}
|
||
|
||
private func presentExternalLinkConfirmation(for url: URL) {
|
||
let alert = UIAlertController(
|
||
title: "打开外部链接",
|
||
message: url.absoluteString,
|
||
preferredStyle: .alert
|
||
)
|
||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||
alert.addAction(UIAlertAction(title: "打开", style: .default) { _ in
|
||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||
})
|
||
present(alert, animated: true)
|
||
}
|
||
|
||
private func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect) {
|
||
hideAttachmentTooltipIfNeeded()
|
||
|
||
let overlay = RDEPUBAttachmentTooltipOverlayView(frame: view.bounds)
|
||
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||
overlay.onBackgroundTap = { [weak self, weak overlay] in
|
||
guard let self, let overlay else { return }
|
||
self.dismissAttachmentTooltipOverlay(overlay)
|
||
}
|
||
|
||
let tooltip = RDEPUBAttachmentTooltipView()
|
||
tooltip.alpha = 0
|
||
tooltip.configure(text: text, maxWidth: min(view.bounds.width - 48, 320))
|
||
|
||
let anchorRect = sourceView.convert(sourceRect, to: view)
|
||
let horizontalPadding: CGFloat = 24
|
||
let verticalSpacing: CGFloat = 6
|
||
let tooltipSize = tooltip.frame.size
|
||
let idealX = anchorRect.midX - tooltipSize.width / 2
|
||
let minX = horizontalPadding
|
||
let maxX = max(minX, view.bounds.width - horizontalPadding - tooltipSize.width)
|
||
let originX = min(max(idealX, minX), maxX)
|
||
let originY = max(view.safeAreaInsets.top + 12, anchorRect.minY - tooltipSize.height - verticalSpacing)
|
||
let arrowTipX = min(
|
||
max(anchorRect.midX - originX, tooltip.minimumArrowX),
|
||
tooltipSize.width - tooltip.minimumArrowX
|
||
)
|
||
|
||
tooltip.setArrowTipX(arrowTipX)
|
||
tooltip.frame.origin = CGPoint(x: originX, y: originY)
|
||
overlay.addSubview(tooltip)
|
||
view.addSubview(overlay)
|
||
|
||
UIView.animate(withDuration: 0.2) {
|
||
tooltip.alpha = 1
|
||
}
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self, weak overlay] in
|
||
guard let self, let overlay else { return }
|
||
self.dismissAttachmentTooltipOverlay(overlay)
|
||
}
|
||
}
|
||
|
||
private func hideAttachmentTooltipIfNeeded() {
|
||
view.subviews
|
||
.compactMap { $0 as? RDEPUBAttachmentTooltipOverlayView }
|
||
.forEach { $0.removeFromSuperview() }
|
||
}
|
||
|
||
private func dismissAttachmentTooltipOverlay(_ overlay: RDEPUBAttachmentTooltipOverlayView) {
|
||
UIView.animate(withDuration: 0.18, animations: {
|
||
overlay.tooltipView?.alpha = 0
|
||
}, completion: { _ in
|
||
overlay.removeFromSuperview()
|
||
})
|
||
}
|
||
}
|
||
|
||
private final class RDEPUBAttachmentTooltipOverlayView: UIView {
|
||
var onBackgroundTap: (() -> Void)?
|
||
|
||
var tooltipView: RDEPUBAttachmentTooltipView? {
|
||
subviews.compactMap { $0 as? RDEPUBAttachmentTooltipView }.first
|
||
}
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = .clear
|
||
|
||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||
tapGesture.cancelsTouchesInView = false
|
||
addGestureRecognizer(tapGesture)
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
@objc
|
||
private func handleTap(_ gesture: UITapGestureRecognizer) {
|
||
let point = gesture.location(in: self)
|
||
guard let tooltipView else {
|
||
onBackgroundTap?()
|
||
return
|
||
}
|
||
if !tooltipView.frame.contains(point) {
|
||
onBackgroundTap?()
|
||
}
|
||
}
|
||
}
|
||
|
||
private final class RDEPUBAttachmentTooltipView: UIView {
|
||
private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20)
|
||
private let arrowSize = CGSize(width: 20, height: 10)
|
||
private let cornerRadius: CGFloat = 18
|
||
private(set) var minimumArrowX: CGFloat = 28
|
||
private var arrowTipX: CGFloat?
|
||
private let textLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.numberOfLines = 0
|
||
label.textColor = .white
|
||
label.font = .systemFont(ofSize: 16, weight: .regular)
|
||
label.lineBreakMode = .byWordWrapping
|
||
return label
|
||
}()
|
||
private let shapeLayer = CAShapeLayer()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = .clear
|
||
isOpaque = false
|
||
layer.addSublayer(shapeLayer)
|
||
addSubview(textLabel)
|
||
accessibilityIdentifier = "epub.reader.attachment.tooltip"
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
shapeLayer.frame = bounds
|
||
shapeLayer.path = bubblePath(in: bounds).cgPath
|
||
shapeLayer.fillColor = UIColor(white: 0.26, alpha: 0.96).cgColor
|
||
|
||
let labelFrame = bounds.inset(by: UIEdgeInsets(
|
||
top: contentInsets.top,
|
||
left: contentInsets.left,
|
||
bottom: contentInsets.bottom + arrowSize.height,
|
||
right: contentInsets.right
|
||
))
|
||
textLabel.frame = labelFrame
|
||
}
|
||
|
||
func setArrowTipX(_ value: CGFloat) {
|
||
arrowTipX = value
|
||
setNeedsLayout()
|
||
}
|
||
|
||
func configure(text: String, maxWidth: CGFloat) {
|
||
textLabel.text = text
|
||
let labelMaxWidth = max(maxWidth - contentInsets.left - contentInsets.right, 120)
|
||
let labelSize = textLabel.sizeThatFits(CGSize(width: labelMaxWidth, height: .greatestFiniteMagnitude))
|
||
frame.size = CGSize(
|
||
width: min(maxWidth, labelSize.width + contentInsets.left + contentInsets.right),
|
||
height: labelSize.height + contentInsets.top + contentInsets.bottom + arrowSize.height
|
||
)
|
||
setNeedsLayout()
|
||
layoutIfNeeded()
|
||
}
|
||
|
||
private func bubblePath(in rect: CGRect) -> UIBezierPath {
|
||
let bubbleRect = CGRect(
|
||
x: rect.minX,
|
||
y: rect.minY,
|
||
width: rect.width,
|
||
height: rect.height - arrowSize.height
|
||
)
|
||
let arrowMidX = min(
|
||
max(arrowTipX ?? bubbleRect.midX, minimumArrowX),
|
||
bubbleRect.width - minimumArrowX
|
||
)
|
||
let arrowHalfWidth = arrowSize.width / 2
|
||
|
||
let path = UIBezierPath()
|
||
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
|
||
path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY))
|
||
path.addArc(
|
||
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius),
|
||
radius: cornerRadius,
|
||
startAngle: -.pi / 2,
|
||
endAngle: 0,
|
||
clockwise: true
|
||
)
|
||
path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius))
|
||
path.addArc(
|
||
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius),
|
||
radius: cornerRadius,
|
||
startAngle: 0,
|
||
endAngle: .pi / 2,
|
||
clockwise: true
|
||
)
|
||
path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY))
|
||
path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.maxY + arrowSize.height))
|
||
path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.maxY))
|
||
path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY))
|
||
path.addArc(
|
||
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius),
|
||
radius: cornerRadius,
|
||
startAngle: .pi / 2,
|
||
endAngle: .pi,
|
||
clockwise: true
|
||
)
|
||
path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius))
|
||
path.addArc(
|
||
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius),
|
||
radius: cornerRadius,
|
||
startAngle: .pi,
|
||
endAngle: -.pi / 2,
|
||
clockwise: true
|
||
)
|
||
path.close()
|
||
return path
|
||
}
|
||
}
|