- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
671 lines
26 KiB
Swift
671 lines
26 KiB
Swift
|
|
import UIKit
|
|
|
|
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
|
|
if let selection {
|
|
updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex))
|
|
} else {
|
|
updateCurrentSelection(nil)
|
|
}
|
|
}
|
|
|
|
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
|
|
handleSelectionMenuAction(action, selection: currentSelection)
|
|
}
|
|
|
|
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
|
|
if presentNotePopupIfPossible(for: location, fromSpineIndex: fromSpineIndex) {
|
|
return
|
|
}
|
|
|
|
guard let readingSession,
|
|
let pageNumber = readingSession.queueNavigation(
|
|
to: location,
|
|
relativeToSpineIndex: fromSpineIndex,
|
|
bookIdentifier: currentBookIdentifier
|
|
) else {
|
|
return
|
|
}
|
|
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
|
|
}
|
|
|
|
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
|
|
delegate?.epubReader(self, didActivateExternalLink: url)
|
|
openExternalURLIfAllowed(url)
|
|
}
|
|
|
|
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
|
|
#if DEBUG
|
|
print("EPUB JS Error: \(message)")
|
|
#endif
|
|
}
|
|
}
|
|
|
|
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
|
|
|
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
|
|
guard let selection else {
|
|
updateCurrentSelection(nil)
|
|
return
|
|
}
|
|
updateCurrentSelection(selection)
|
|
}
|
|
|
|
func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) {
|
|
readerView.handleContentTap(at: point, in: contentView)
|
|
}
|
|
|
|
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,
|
|
sourcePoint: CGPoint
|
|
) {
|
|
presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect, sourcePoint: sourcePoint)
|
|
}
|
|
|
|
func textContentView(
|
|
_ contentView: RDEPUBTextContentView,
|
|
didRequestHighlightActions highlight: RDEPUBHighlight,
|
|
sourceRect: CGRect
|
|
) {
|
|
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
|
|
}
|
|
|
|
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 startOffset = resolvedPage.page.pageStartOffset
|
|
let endOffset = max(startOffset, resolvedPage.page.pageEndOffset)
|
|
let chapterData = makeRuntimeChapterData(from: resolvedPage)
|
|
let location = chapterData.location(
|
|
for: NSRange(location: startOffset, length: max(endOffset - startOffset + 1, 1)),
|
|
bookIdentifier: currentBookIdentifier
|
|
)
|
|
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
|
|
}
|
|
|
|
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 spineIndex = readerContext.normalizedSpineIndex(for: location),
|
|
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
|
|
let offset = runtimeChapter.chapterOffsetMap.chapterOffset(forCFI: location.cfi) {
|
|
return offset
|
|
}
|
|
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
|
let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset {
|
|
return cfiOffset
|
|
}
|
|
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
|
|
}
|
|
|
|
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 presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool {
|
|
guard let publication else { return false }
|
|
let sourceHref = publication.resourceResolver.href(forSpineIndex: fromSpineIndex) ?? location.href
|
|
let sourceLocation = currentVisibleLocation()
|
|
let sourceCFI = sourceLocation?.cfi
|
|
let resolver = RDEPUBNoteResolver(resourceResolver: publication.resourceResolver)
|
|
guard let note = resolver.resolveInternalLink(
|
|
sourceHref: sourceHref,
|
|
sourceCFI: sourceCFI,
|
|
targetLocation: location,
|
|
sourceLocation: sourceLocation,
|
|
relativeToSpineIndex: fromSpineIndex
|
|
) else {
|
|
return false
|
|
}
|
|
|
|
RDEPUBNotePopupCoordinator.present(
|
|
note,
|
|
from: self,
|
|
onReturnToSource: { [weak self] in
|
|
guard let self, let sourceLocation = note.sourceLocation else { return }
|
|
self.navigateToLocation(sourceLocation, relativeToSpineIndex: nil, animated: true)
|
|
},
|
|
onOpenNoteLocation: { [weak self] in
|
|
guard let self else { return }
|
|
self.navigateToLocation(note.targetLocation, relativeToSpineIndex: nil, animated: true)
|
|
}
|
|
)
|
|
return true
|
|
}
|
|
|
|
private func navigateToLocation(
|
|
_ location: RDEPUBLocation,
|
|
relativeToSpineIndex spineIndex: Int?,
|
|
animated: Bool
|
|
) {
|
|
guard let pageNumber = readingSession?.queueNavigation(
|
|
to: location,
|
|
relativeToSpineIndex: spineIndex,
|
|
bookIdentifier: currentBookIdentifier
|
|
) else {
|
|
return
|
|
}
|
|
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: animated)
|
|
}
|
|
|
|
private func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect, sourcePoint: CGPoint) {
|
|
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
|
|
let horizontalPadding = max(view.safeAreaInsets.left, view.safeAreaInsets.right) + 12
|
|
tooltip.configure(text: text, maxWidth: min(view.bounds.width - horizontalPadding * 2, 320))
|
|
|
|
let anchorRect = sourceView.convert(sourceRect, to: view)
|
|
let rawAnchorPoint = sourceView.convert(sourcePoint, to: view)
|
|
let anchorPoint = CGPoint(
|
|
x: min(max(rawAnchorPoint.x, anchorRect.minX), anchorRect.maxX),
|
|
y: min(max(rawAnchorPoint.y, anchorRect.minY), anchorRect.maxY)
|
|
)
|
|
let verticalSpacing: CGFloat = 6
|
|
let tooltipSize = tooltip.frame.size
|
|
let idealX = anchorPoint.x - tooltipSize.width / 2
|
|
let minX = horizontalPadding
|
|
let maxX = max(minX, view.bounds.width - horizontalPadding - tooltipSize.width)
|
|
let originX = min(max(idealX, minX), maxX)
|
|
let topSafeY = view.safeAreaInsets.top + 12
|
|
let bottomSafeY = view.bounds.height - view.safeAreaInsets.bottom - 12
|
|
let availableSpaceAbove = anchorRect.minY - topSafeY
|
|
let availableSpaceBelow = bottomSafeY - anchorRect.maxY
|
|
let prefersAbove = availableSpaceAbove >= tooltipSize.height + verticalSpacing || availableSpaceAbove >= availableSpaceBelow
|
|
let tooltipPlacement: RDEPUBAttachmentTooltipView.ArrowPlacement = prefersAbove ? .bottom : .top
|
|
let originY: CGFloat
|
|
switch tooltipPlacement {
|
|
case .bottom:
|
|
originY = max(topSafeY, anchorRect.minY - tooltipSize.height - verticalSpacing)
|
|
case .top:
|
|
originY = min(bottomSafeY - tooltipSize.height, anchorRect.maxY + verticalSpacing)
|
|
}
|
|
let arrowTipX = min(
|
|
max(anchorPoint.x - originX, tooltip.minimumArrowX),
|
|
tooltipSize.width - tooltip.minimumArrowX
|
|
)
|
|
|
|
tooltip.setArrowTipX(arrowTipX, placement: tooltipPlacement)
|
|
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 extension RDEPUBReaderController {
|
|
func makeRuntimeChapterData(from resolvedPage: RDEPUBResolvedPage) -> RDEPUBChapterData {
|
|
let textChapter = RDEPUBTextChapter(
|
|
chapterIndex: resolvedPage.chapterIndex,
|
|
spineIndex: resolvedPage.chapter.spineIndex,
|
|
href: resolvedPage.chapter.href,
|
|
title: resolvedPage.chapter.title,
|
|
attributedContent: resolvedPage.chapter.typesetAttributedString,
|
|
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
|
|
cfiMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
|
|
pageBreakReasons: resolvedPage.chapter.pages.map(\.metadata.breakReason),
|
|
pages: resolvedPage.chapter.pages
|
|
)
|
|
return RDEPUBChapterData(
|
|
chapter: textChapter,
|
|
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
|
|
)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
|
|
enum ArrowPlacement {
|
|
|
|
case top
|
|
|
|
case bottom
|
|
}
|
|
|
|
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 var arrowPlacement: ArrowPlacement = .bottom
|
|
|
|
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 topInset = contentInsets.top + (arrowPlacement == .top ? arrowSize.height : 0)
|
|
let bottomInset = contentInsets.bottom + (arrowPlacement == .bottom ? arrowSize.height : 0)
|
|
let labelFrame = bounds.inset(by: UIEdgeInsets(
|
|
top: topInset,
|
|
left: contentInsets.left,
|
|
bottom: bottomInset,
|
|
right: contentInsets.right
|
|
))
|
|
textLabel.frame = labelFrame
|
|
}
|
|
|
|
func setArrowTipX(_ value: CGFloat, placement: ArrowPlacement) {
|
|
arrowTipX = value
|
|
arrowPlacement = placement
|
|
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
|
|
switch arrowPlacement {
|
|
case .bottom:
|
|
bubbleRect = CGRect(
|
|
x: rect.minX,
|
|
y: rect.minY,
|
|
width: rect.width,
|
|
height: rect.height - arrowSize.height
|
|
)
|
|
case .top:
|
|
bubbleRect = CGRect(
|
|
x: rect.minX,
|
|
y: rect.minY + arrowSize.height,
|
|
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()
|
|
switch arrowPlacement {
|
|
case .bottom:
|
|
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
|
|
)
|
|
case .top:
|
|
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
|
|
path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.minY))
|
|
path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.minY - arrowSize.height))
|
|
path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, 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: 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
|
|
}
|
|
}
|