// // 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 *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 *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