ReadViewSDK/Doc/WXRead/decompiled/WRPageView.m

646 lines
24 KiB
Objective-C
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// WRPageView.m
// WeRead (读书)
//
// Detailed pseudo-code reconstruction of the page rendering view.
// This view draws text directly via CoreText into CGContext — no UILabel
// or UITextView is used for the reading content.
//
#import "WRPageView.h"
#import "WRChapterData.h"
#import "WRCoreTextLayoutFrame.h"
#import "WRActivityIndicator.h"
#import "WRLoadingProgressView.h"
#import "WRFriendReviewsButton.h"
// ============================================================================
#pragma mark - Private Helpers
// ============================================================================
/// Extracts a plain-text NSString from a given range of the chapter's
/// attributed string, stripping any attachment characters.
static NSString *WRPlainStringFromRange(NSAttributedString *attrStr, NSRange range) {
if (!attrStr || range.location == NSNotFound) return @"";
NSString *raw = [[attrStr string] substringWithRange:range];
// Remove object replacement characters used for image attachments.
return [raw stringByReplacingOccurrencesOfString:@"" withString:@""];
}
/// Converts a UITouch point from view coordinates to the flipped coordinate
/// system expected by CoreText (origin at bottom-left).
static CGPoint WRSFlipPointForCoreText(CGPoint viewPoint, CGFloat viewHeight) {
return CGPointMake(viewPoint.x, viewHeight - viewPoint.y);
}
// ============================================================================
#pragma mark - WRPageView ()
// ============================================================================
@interface WRPageView ()
// ---- Private selection tracking ----
@property (nonatomic, assign) NSInteger selectionStartIndex;
@property (nonatomic, assign) NSInteger selectionEndIndex;
@property (nonatomic, assign) BOOL isSelecting;
// ---- Gesture recognizers ----
@property (nonatomic, strong) UITapGestureRecognizer *singleTapGR;
@property (nonatomic, strong) UILongPressGestureRecognizer *longPressGR;
@property (nonatomic, strong) UIPanGestureRecognizer *panGR;
// ---- Highlight/underline drawing cache ----
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *highlightRects;
@end
// ============================================================================
#pragma mark - WRPageView Implementation
// ============================================================================
@implementation WRPageView
// ---- Synthesize properties backed by ivars from the binary ----
// The binary shows these ivar types:
// NSArray -> imageAttachments
// NSMutableArray -> visibleHighlights
// NSTimer -> autoReadTimer
// NSTimer -> loadingTimeoutTimer
// NSString -> chapterId
// UILabel -> headerLabel
// UIImageView -> backgroundImageView
// WRActivityIndicator -> activityIndicator
// UILabel -> statusLabel
// QMUIButton -> retryButton
// QMUIButton -> shareButton
// WRLoadingProgressView -> loadingProgressView
// QMUIButton -> bookmarkButton
// QMUIButton -> fontSizeUpButton
// QMUIButton -> fontSizeDownButton
// WRFriendReviewsButton -> friendReviewsButton
// ============================================================================
#pragma mark - Lifecycle
// ============================================================================
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
// CoreText views must be opaque and have a cleared background for
// the CGContext to render correctly.
self.opaque = YES;
self.backgroundColor = [UIColor whiteColor];
self.clearsContextBeforeDrawing = YES;
// Enable multiple touches for selection handles.
self.multipleTouchEnabled = YES;
_selectionStartIndex = NSNotFound;
_selectionEndIndex = NSNotFound;
_isSelecting = NO;
[self _setupSubviews];
[self _setupGestureRecognizers];
}
return self;
}
- (void)dealloc {
[_autoReadTimer invalidate];
[_loadingTimeoutTimer invalidate];
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
// ============================================================================
#pragma mark - Subview Setup
// ============================================================================
/// Creates the non-text overlay UI elements. The reading text itself is
/// rendered entirely in -drawRect: via CoreText, so these are all floating
/// controls layered on top.
- (void)_setupSubviews {
// ---- Background image (e.g., paper texture) ----
_backgroundImageView = [[UIImageView alloc] initWithFrame:self.bounds];
_backgroundImageView.autoresizingMask = UIViewAutoresizingFlexibleWidth |
UIViewAutoresizingFlexibleHeight;
_backgroundImageView.contentMode = UIViewContentModeScaleAspectFill;
[self addSubview:_backgroundImageView];
// ---- Header label (chapter title, page indicator) ----
_headerLabel = [[UILabel alloc] initWithFrame:CGRectZero];
_headerLabel.font = [UIFont systemFontOfSize:12.0];
_headerLabel.textColor = [UIColor grayColor];
_headerLabel.textAlignment = NSTextAlignmentCenter;
[self addSubview:_headerLabel];
// ---- Activity indicator (shown during chapter load) ----
_activityIndicator = [[WRActivityIndicator alloc] initWithFrame:CGRectZero];
_activityIndicator.hidden = YES;
[self addSubview:_activityIndicator];
// ---- Status label (error / empty state) ----
_statusLabel = [[UILabel alloc] initWithFrame:CGRectZero];
_statusLabel.font = [UIFont systemFontOfSize:15.0];
_statusLabel.textColor = [UIColor darkGrayColor];
_statusLabel.textAlignment = NSTextAlignmentCenter;
_statusLabel.numberOfLines = 0;
_statusLabel.hidden = YES;
[self addSubview:_statusLabel];
// ---- Retry button ----
_retryButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
[_retryButton setTitle:@"重试" forState:UIControlStateNormal]; // "Retry"
[_retryButton addTarget:self
action:@selector(_retryButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
_retryButton.hidden = YES;
[self addSubview:_retryButton];
// ---- Share button ----
_shareButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
[_shareButton setImage:[UIImage imageNamed:@"icon_share"]
forState:UIControlStateNormal];
[_shareButton addTarget:self
action:@selector(_shareButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
_shareButton.hidden = YES;
[self addSubview:_shareButton];
// ---- Loading progress view ----
_loadingProgressView = [[WRLoadingProgressView alloc] initWithFrame:CGRectZero];
_loadingProgressView.hidden = YES;
[self addSubview:_loadingProgressView];
// ---- Bookmark button ----
_bookmarkButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
[_bookmarkButton addTarget:self
action:@selector(_bookmarkButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
[self addSubview:_bookmarkButton];
// ---- Font size buttons ----
_fontSizeUpButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
[_fontSizeUpButton setTitle:@"A+" forState:UIControlStateNormal];
[_fontSizeUpButton addTarget:self
action:@selector(_fontSizeUpTapped:)
forControlEvents:UIControlEventTouchUpInside];
[self addSubview:_fontSizeUpButton];
_fontSizeDownButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
[_fontSizeDownButton setTitle:@"A-" forState:UIControlStateNormal];
[_fontSizeDownButton addTarget:self
action:@selector(_fontSizeDownTapped:)
forControlEvents:UIControlEventTouchUpInside];
[self addSubview:_fontSizeDownButton];
// ---- Friend reviews button ----
_friendReviewsButton = [[WRFriendReviewsButton alloc] initWithFrame:CGRectZero];
[_friendReviewsButton addTarget:self
action:@selector(_friendReviewsTapped:)
forControlEvents:UIControlEventTouchUpInside];
_friendReviewsButton.hidden = YES;
[self addSubview:_friendReviewsButton];
}
// ============================================================================
#pragma mark - Gesture Recognizers
// ============================================================================
- (void)_setupGestureRecognizers {
// Single tap: link detection, selection dismissal, toolbar toggle.
_singleTapGR = [[UITapGestureRecognizer alloc]
initWithTarget:self action:@selector(_handleSingleTap:)];
_singleTapGR.numberOfTapsRequired = 1;
[self addGestureRecognizer:_singleTapGR];
// Long press: initiate text selection.
_longPressGR = [[UILongPressGestureRecognizer alloc]
initWithTarget:self action:@selector(_handleLongPress:)];
_longPressGR.minimumPressDuration = 0.5;
[self addGestureRecognizer:_longPressGR];
// Pan: extend selection after long press.
_panGR = [[UIPanGestureRecognizer alloc]
initWithTarget:self action:@selector(_handlePan:)];
_panGR.enabled = NO; // Enabled only when selecting.
[self addGestureRecognizer:_panGR];
// Long press should fail before single tap fires.
[_singleTapGR requireGestureRecognizerToFail:_longPressGR];
}
// ============================================================================
#pragma mark - Layout
// ============================================================================
- (void)layoutSubviews {
[super layoutSubviews];
// Position the header label at the top edge with padding.
CGFloat headerHeight = 20.0;
_headerLabel.frame = CGRectMake(16.0, 8.0,
CGRectGetWidth(self.bounds) - 32.0,
headerHeight);
// Center the activity indicator.
_activityIndicator.center = CGPointMake(CGRectGetMidX(self.bounds),
CGRectGetMidY(self.bounds));
// Position the friend reviews button at bottom-right.
CGSize reviewSize = CGSizeMake(60.0, 30.0);
_friendReviewsButton.frame =
CGRectMake(CGRectGetWidth(self.bounds) - reviewSize.width - 16.0,
CGRectGetHeight(self.bounds) - reviewSize.height - 40.0,
reviewSize.width, reviewSize.height);
}
// ============================================================================
#pragma mark - CoreText Drawing (drawRect:)
// ============================================================================
///
/// This is the heart of the page view. It draws the chapter text directly
/// into the CGContext using CoreText. No UILabel or UITextView is involved.
///
/// The flow is:
/// 1. Get the current graphics context.
/// 2. Flip the coordinate system for CoreText (origin at bottom-left).
/// 3. Call WRCoreTextLayoutFrame to draw the attributed string runs.
/// 4. Draw inline images at their attachment positions.
/// 5. Draw highlight/underline overlays.
///
- (void)drawRect:(CGRect)rect {
CGContextRef ctx = UIGraphicsGetCurrentContext();
if (!ctx) return;
// If there is no chapter data or layout frame, just clear and return.
if (!self.chapterData || !self.layoutFrame) {
CGContextClearRect(ctx, rect);
return;
}
// ---- Step 1: Fill background ----
UIColor *bgColor = self.backgroundColor ?: [UIColor whiteColor];
CGContextSetFillColorWithColor(ctx, bgColor.CGColor);
CGContextFillRect(ctx, rect);
// ---- Step 2: Flip coordinate system for CoreText ----
// CoreText uses a bottom-left origin; UIKit uses top-left.
CGContextSetTextMatrix(ctx, CGAffineTransformIdentity);
CGContextTranslateCTM(ctx, 0.0, CGRectGetHeight(self.bounds));
CGContextScaleCTM(ctx, 1.0, -1.0);
// ---- Step 3: Draw the layout frame ----
// This calls WRCoreTextLayoutFrame -drawInContext:image:size:inRect:position:
// which iterates over CTLine objects, draws glyphs, and positions images.
CGSize pageSize = self.bounds.size;
CGPoint drawOrigin = CGPointMake(0.0, 0.0); // Could include margins.
CGRect drawRect = UIEdgeInsetsInsetRect(self.bounds,
self.chapterData.contentInsets);
[self.layoutFrame drawInContext:ctx
image:self.backgroundImageView.image
size:pageSize
inRect:drawRect
position:drawOrigin];
// ---- Step 4: Draw highlights and underlines ----
[self _drawHighlightsInContext:ctx];
// ---- Step 5: Draw selection handles if selecting ----
if (self.isSelecting) {
[self _drawSelectionInContext:ctx];
}
}
/// Draws highlight rectangles and underline paths for annotations,
/// bookmarks, and the current selection.
- (void)_drawHighlightsInContext:(CGContextRef)ctx {
for (NSDictionary *entry in self.highlightRects) {
CGRect hlRect = [entry[@"rect"] CGRectValue];
UIColor *color = entry[@"color"] ?: [UIColor yellowColor];
BOOL isUnderline = [entry[@"underline"] boolValue];
CGContextSetFillColorWithColor(ctx,
[color colorWithAlphaComponent:0.3].CGColor);
CGContextFillRect(ctx, hlRect);
if (isUnderline) {
CGFloat y = CGRectGetMaxY(hlRect);
CGContextSetStrokeColorWithColor(ctx, color.CGColor);
CGContextSetLineWidth(ctx, 1.0);
CGContextMoveToPoint(ctx, CGRectGetMinX(hlRect), y);
CGContextAddLineToPoint(ctx, CGRectGetMaxX(hlRect), y);
CGContextStrokePath(ctx);
}
}
}
/// Draws the selection highlight between selectionStartIndex and
/// selectionEndIndex using CTLineGetOffsetForStringIndex.
- (void)_drawSelectionInContext:(CGContextRef)ctx {
if (_selectionStartIndex == NSNotFound || _selectionEndIndex == NSNotFound) {
return;
}
// Clamp range.
NSInteger lo = MIN(_selectionStartIndex, _selectionEndIndex);
NSInteger hi = MAX(_selectionStartIndex, _selectionEndIndex);
NSRange selRange = NSMakeRange((NSUInteger)lo, (NSUInteger)(hi - lo));
// Ask the layout frame for the rects covering this range.
NSArray<NSValue *> *rects = [self.layoutFrame rectsWithinRange:selRange];
UIColor *selColor = [UIColor colorWithRed:0.2 green:0.5 blue:1.0 alpha:0.3];
for (NSValue *val in rects) {
CGRect r = [val CGRectValue];
CGContextSetFillColorWithColor(ctx, selColor.CGColor);
CGContextFillRect(ctx, r);
}
}
// ============================================================================
#pragma mark - Drawing API (called externally)
// ============================================================================
/// External entry point for drawing. Delegates to the layout frame.
- (void)drawInContext:(CGContextRef)context
withData:(WRChapterData *)chapterData
size:(CGSize)size
inRect:(CGRect)rect
position:(CGPoint)position {
// Store references so -drawRect: can use them.
self.chapterData = chapterData;
// Trigger a redraw.
[self setNeedsDisplay];
}
// ============================================================================
#pragma mark - Text Selection via CoreText Hit Testing
// ============================================================================
/// Converts a view-coordinate point to a string index in the attributed string
/// using CTLineGetStringIndexForPosition.
- (NSInteger)stringIndexForPoint:(CGPoint)point {
if (!self.layoutFrame) return NSNotFound;
// Flip Y for CoreText.
CGPoint ctPoint = WRSFlipPointForCoreText(point, CGRectGetHeight(self.bounds));
// Walk the CTLine objects in the layout frame to find the line at this Y,
// then use CTLineGetStringIndexForPosition to get the character index.
NSInteger index = [self.layoutFrame stringIndexForPoint:ctPoint];
return index;
}
/// Returns the range of the line containing the given string index,
/// using CTLineGetOffsetForStringIndex to find line boundaries.
- (NSRange)lineRangeForStringIndex:(NSInteger)index {
if (!self.layoutFrame || index == NSNotFound) {
return NSMakeRange(NSNotFound, 0);
}
return [self.layoutFrame lineRangeForStringIndex:index];
}
/// Clears the current text selection and disables the pan gesture.
- (void)clearSelection {
_selectionStartIndex = NSNotFound;
_selectionEndIndex = NSNotFound;
_isSelecting = NO;
_panGR.enabled = NO;
[self setNeedsDisplay];
}
// ============================================================================
#pragma mark - Gesture Handlers
// ============================================================================
- (void)_handleSingleTap:(UITapGestureRecognizer *)gr {
CGPoint pt = [gr locationInView:self];
// If currently selecting, dismiss selection.
if (self.isSelecting) {
[self clearSelection];
return;
}
// Check if the tap hits a link in the layout frame.
NSInteger idx = [self stringIndexForPoint:pt];
if (idx != NSNotFound) {
NSURL *linkURL = [self.layoutFrame linkURLAtIndex:idx];
if (linkURL) {
if ([self.delegate respondsToSelector:@selector(pageView:didTapLinkWithURL:)]) {
[self.delegate pageView:self didTapLinkWithURL:linkURL];
}
return;
}
}
// Otherwise, toggle toolbar / delegate the tap.
// (In the real app, this toggles the reader chrome.)
}
- (void)_handleLongPress:(UILongPressGestureRecognizer *)gr {
if (gr.state == UIGestureRecognizerStateBegan) {
CGPoint pt = [gr locationInView:self];
NSInteger idx = [self stringIndexForPoint:pt];
if (idx != NSNotFound) {
_selectionStartIndex = idx;
_selectionEndIndex = idx;
_isSelecting = YES;
_panGR.enabled = YES;
[self setNeedsDisplay];
if ([self.delegate respondsToSelector:@selector(pageView:didBeginSelectionAtPoint:)]) {
[self.delegate pageView:self didBeginSelectionAtPoint:pt];
}
}
}
}
- (void)_handlePan:(UIPanGestureRecognizer *)gr {
if (!self.isSelecting) return;
CGPoint pt = [gr locationInView:self];
NSInteger idx = [self stringIndexForPoint:pt];
if (idx != NSNotFound && idx != _selectionEndIndex) {
_selectionEndIndex = idx;
[self setNeedsDisplay];
// Notify delegate of selection range change.
NSInteger lo = MIN(_selectionStartIndex, _selectionEndIndex);
NSInteger hi = MAX(_selectionStartIndex, _selectionEndIndex);
NSRange selRange = NSMakeRange((NSUInteger)lo, (NSUInteger)(hi - lo));
if ([self.delegate respondsToSelector:@selector(pageView:selectionDidChangeWithRange:)]) {
[self.delegate pageView:self selectionDidChangeWithRange:selRange];
}
}
}
// ============================================================================
#pragma mark - Accessibility
// ============================================================================
/// Provides a plain-text representation of the page for VoiceOver.
- (nullable NSString *)accessibilityValue {
if (!self.chapterData) return nil;
NSAttributedString *attrStr = self.chapterData.typesetAttributedString;
if (!attrStr) return nil;
// Return the plain text for the current page range.
NSRange pageRange = [self.chapterData rangeOfPage:self.pageIndex];
return WRPlainStringFromRange(attrStr, pageRange);
}
/// Supports VoiceOver scroll gestures to flip pages.
- (BOOL)accessibilityScroll:(UIAccessibilityScrollDirection)direction {
// Post a page-change notification for the WRPageViewController to handle.
NSString *dirStr = nil;
switch (direction) {
case UIAccessibilityScrollDirectionLeft:
dirStr = @"next";
break;
case UIAccessibilityScrollDirectionRight:
dirStr = @"previous";
break;
default:
return NO;
}
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewAccessibilityScroll"
object:self
userInfo:@{@"direction": dirStr}];
return YES;
}
// ============================================================================
#pragma mark - Friend Reviews
// ============================================================================
- (void)friendReviewsCount:(NSUInteger)count {
self.friendReviewsButton.hidden = (count == 0);
[self.friendReviewsButton setReviewCount:count];
}
// ============================================================================
#pragma mark - Loading / Error States
// ============================================================================
- (void)showLoadingWithProgress:(float)progress {
self.activityIndicator.hidden = NO;
[self.activityIndicator startAnimating];
if (progress > 0.0 && progress < 1.0) {
self.loadingProgressView.hidden = NO;
self.loadingProgressView.progress = progress;
} else {
self.loadingProgressView.hidden = YES;
}
// Start a timeout timer — if loading takes too long, show an error.
[_loadingTimeoutTimer invalidate];
_loadingTimeoutTimer =
[NSTimer scheduledTimerWithTimeInterval:15.0
target:self
selector:@selector(_loadingDidTimeout)
userInfo:nil
repeats:NO];
}
- (void)hideLoading {
[_loadingTimeoutTimer invalidate];
_loadingTimeoutTimer = nil;
[self.activityIndicator stopAnimating];
self.activityIndicator.hidden = YES;
self.loadingProgressView.hidden = YES;
}
- (void)showErrorWithMessage:(NSString *)message {
[self hideLoading];
self.statusLabel.text = message;
self.statusLabel.hidden = NO;
self.retryButton.hidden = NO;
}
- (void)_loadingDidTimeout {
[self showErrorWithMessage:@"加载超时,请重试"]; // "Loading timed out, please retry"
}
// ============================================================================
#pragma mark - Button Actions
// ============================================================================
- (void)_retryButtonTapped:(QMUIButton *)sender {
self.statusLabel.hidden = YES;
self.retryButton.hidden = YES;
if ([self.delegate respondsToSelector:@selector(pageViewNeedsReload:)]) {
[self.delegate pageViewNeedsReload:self];
}
}
- (void)_shareButtonTapped:(QMUIButton *)sender {
// Share current page content.
// Handled by delegate or responder chain.
}
- (void)_bookmarkButtonTapped:(QMUIButton *)sender {
// Toggle bookmark for current page.
sender.selected = !sender.selected;
}
- (void)_fontSizeUpTapped:(QMUIButton *)sender {
// Increase font size — triggers re-typeset.
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewFontSizeChange"
object:self
userInfo:@{@"delta": @(1)}];
}
- (void)_fontSizeDownTapped:(QMUIButton *)sender {
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewFontSizeChange"
object:self
userInfo:@{@"delta": @(-1)}];
}
- (void)_friendReviewsTapped:(QMUIButton *)sender {
if ([self.delegate respondsToSelector:@selector(pageView:didTapFriendReviewsWithCount:)]) {
[self.delegate pageView:self
didTapFriendReviewsWithCount:sender.tag];
}
}
// ============================================================================
#pragma mark - Auto-Read Timer
// ============================================================================
/// Starts the auto-read timer that periodically advances the page.
- (void)startAutoReadWithInterval:(NSTimeInterval)interval {
[_autoReadTimer invalidate];
_autoReadTimer =
[NSTimer scheduledTimerWithTimeInterval:interval
target:self
selector:@selector(_autoReadTick)
userInfo:nil
repeats:YES];
}
- (void)stopAutoRead {
[_autoReadTimer invalidate];
_autoReadTimer = nil;
}
- (void)_autoReadTick {
// Post notification for the page view controller to advance.
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewAutoReadAdvance"
object:self];
}
@end