// // WRPageViewController.m // WeRead (微信读书) // // Detailed pseudo-code reconstruction of the page view controller. // Wraps UIPageViewController with fault detection and patching for // known Apple bugs in the page curl and scroll transition styles. // #import "WRPageViewController.h" #import // ============================================================================ #pragma mark - Constants // ============================================================================ /// Notification posted when a page transition completes. static NSString *const kWRPageDidTransitionNotification = @"WRPageViewControllerDidTransition"; /// UserDefaults key to track whether the nav-direction fault has been patched. static NSString *const kNavDirectionPatchedKey = @"WRPageVC_NavDirectionPatched"; // ============================================================================ #pragma mark - Private Forward Declarations // ============================================================================ @interface WRPageViewController () @property (nonatomic, strong) UIPageViewController *uiPageViewController; @property (nonatomic, assign) NSUInteger currentPageIndex; @property (nonatomic, assign) BOOL isTransitioning; /// Pending completion block for -setViewControllers:direction:animated:completion:. @property (nonatomic, copy, nullable) void (^pendingTransitionCompletion)(BOOL); @end // ============================================================================ #pragma mark - Fault Patching State // ============================================================================ /// Static flag: whether the navigation direction fault has been detected /// in this process lifetime. static BOOL s_NavDirectionFaultDetected = NO; /// Static flag: whether the page curl fault has been patched. static BOOL s_PageCurlFaultPatched = NO; // ============================================================================ #pragma mark - WRPageViewController Implementation // ============================================================================ @implementation WRPageViewController // ============================================================================ #pragma mark - Initialization // ============================================================================ - (instancetype)initWithDelegate:(id)delegate withPageType:(UIPageViewControllerTransitionStyle)pageType pageFlippingStyle:(WRPageFlippingStyle)flippingStyle { self = [super initWithNibName:nil bundle:nil]; if (self) { _pageDelegate = delegate; _pageFlippingStyle = flippingStyle; _pagingEnabled = YES; _currentPageIndex = 0; _isTransitioning = NO; // Determine the UIPageViewController transition style. UIPageViewControllerTransitionStyle uiStyle; switch (flippingStyle) { case WRPageFlippingStyleCurl: uiStyle = UIPageViewControllerTransitionStylePageCurl; break; case WRPageFlippingStyleSlide: case WRPageFlippingStyleFade: case WRPageFlippingStyleNone: default: uiStyle = UIPageViewControllerTransitionStyleScroll; break; } // Create the underlying UIPageViewController. NSDictionary *options = @{ UIPageViewControllerOptionInterPageSpacingKey: @(20.0), }; _uiPageViewController = [[UIPageViewController alloc] initWithTransitionStyle:uiStyle navigationOrientation:UIPageViewControllerNavigationOrientationHorizontal options:options]; _uiPageViewController.delegate = self; _uiPageViewController.dataSource = self; // Apply known fault patches proactively. [WRPageViewController patchNavigationDirectionFault]; if (flippingStyle == WRPageFlippingStyleCurl) { [WRPageViewController patchUIPageCurlFault]; } } return self; } - (void)viewDidLoad { [super viewDidLoad]; // Embed the UIPageViewController as a child. [self addChildViewController:self.uiPageViewController]; self.uiPageViewController.view.frame = self.view.bounds; self.uiPageViewController.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; [self.view addSubview:self.uiPageViewController.view]; [self.uiPageViewController didMoveToParentViewController:self]; // Configure gesture recognizers. [self _configureGestureRecognizers]; } - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; self.uiPageViewController.view.frame = self.view.bounds; } - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; } // ============================================================================ #pragma mark - Gesture Recognizer Configuration // ============================================================================ /// Configures the page view controller's gesture recognizers. /// In scroll mode, the internal UIScrollView handles paging. /// In curl mode, the tap-to-flip gesture is added. - (void)_configureGestureRecognizers { if (self.pageFlippingStyle == WRPageFlippingStyleCurl) { // Add tap zones for curl: left 1/4 and right 1/4 of the screen. UITapGestureRecognizer *leftTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(_handleLeftTap:)]; leftTap.delegate = self; [self.view addGestureRecognizer:leftTap]; UITapGestureRecognizer *rightTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(_handleRightTap:)]; rightTap.delegate = self; [self.view addGestureRecognizer:rightTap]; } } - (void)_handleLeftTap:(UITapGestureRecognizer *)gr { [self goToPreviousPageAnimated:YES]; } - (void)_handleRightTap:(UITapGestureRecognizer *)gr { [self goToNextPageAnimated:YES]; } // ============================================================================ #pragma mark - Page Navigation // ============================================================================ - (void)setCurrentPageIndex:(NSUInteger)pageIndex animated:(BOOL)animated { if (pageIndex == _currentPageIndex) return; UIPageViewControllerNavigationDirection direction = (pageIndex > _currentPageIndex) ? UIPageViewControllerNavigationDirectionForward : UIPageViewControllerNavigationDirectionReverse; _currentPageIndex = pageIndex; // Notify delegate. if ([self.pageDelegate respondsToSelector: @selector(pageViewController:didChangePageIndex:)]) { [self.pageDelegate pageViewController:self didChangePageIndex:pageIndex]; } } - (BOOL)goToNextPageAnimated:(BOOL)animated { if (!self.pagingEnabled || self.isTransitioning) return NO; // Ask the data source for the next view controller. UIViewController *currentVC = self.uiPageViewController.viewControllers.firstObject; if (!currentVC) return NO; UIViewController *nextVC = [self.pageDataSource pageViewController:self viewControllerAfterViewController:currentVC]; if (!nextVC) return NO; // At the end. self.isTransitioning = YES; __weak typeof(self) weakSelf = self; [self.uiPageViewController setViewControllers:@[nextVC] direction:UIPageViewControllerNavigationDirectionForward animated:animated completion:^(BOOL finished) { __strong typeof(weakSelf) strongSelf = weakSelf; strongSelf.isTransitioning = NO; if (finished) { strongSelf.currentPageIndex++; } }]; return YES; } - (BOOL)goToPreviousPageAnimated:(BOOL)animated { if (!self.pagingEnabled || self.isTransitioning) return NO; UIViewController *currentVC = self.uiPageViewController.viewControllers.firstObject; if (!currentVC) return NO; UIViewController *prevVC = [self.pageDataSource pageViewController:self viewControllerBeforeViewController:currentVC]; if (!prevVC) return NO; // At the beginning. self.isTransitioning = YES; __weak typeof(self) weakSelf = self; [self.uiPageViewController setViewControllers:@[prevVC] direction:UIPageViewControllerNavigationDirectionReverse animated:animated completion:^(BOOL finished) { __strong typeof(weakSelf) strongSelf = weakSelf; strongSelf.isTransitioning = NO; if (finished && strongSelf.currentPageIndex > 0) { strongSelf.currentPageIndex--; } }]; return YES; } - (void)setViewControllers:(NSArray *)viewControllers direction:(UIPageViewControllerNavigationDirection)direction animated:(BOOL)animated completion:(void (^ __nullable)(BOOL finished))completion { self.pendingTransitionCompletion = completion; __weak typeof(self) weakSelf = self; [self.uiPageViewController setViewControllers:viewControllers direction:direction animated:animated completion:^(BOOL finished) { __strong typeof(weakSelf) strongSelf = weakSelf; strongSelf.isTransitioning = NO; if (strongSelf.pendingTransitionCompletion) { strongSelf.pendingTransitionCompletion(finished); strongSelf.pendingTransitionCompletion = nil; } }]; } // ============================================================================ #pragma mark - UIPageViewControllerDataSource // ============================================================================ - (UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerBeforeViewController:(UIViewController *)viewController { // Delegate to our data source. if ([self.pageDataSource respondsToSelector: @selector(pageViewController:viewControllerBeforeViewController:)]) { return [self.pageDataSource pageViewController:self viewControllerBeforeViewController:viewController]; } return nil; } - (UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerAfterViewController:(UIViewController *)viewController { if ([self.pageDataSource respondsToSelector: @selector(pageViewController:viewControllerAfterViewController:)]) { return [self.pageDataSource pageViewController:self viewControllerAfterViewController:viewController]; } return nil; } // ============================================================================ #pragma mark - UIPageViewControllerDelegate // ============================================================================ - (void)pageViewController:(UIPageViewController *)pageViewController willTransitionToViewControllers:(NSArray *)pendingViewControllers { self.isTransitioning = YES; if ([self.pageDelegate respondsToSelector: @selector(pageViewController:willTransitionToViewControllers:)]) { [self.pageDelegate pageViewController:self willTransitionToViewControllers:pendingViewControllers]; } } - (void)pageViewController:(UIPageViewController *)pageViewController didFinishAnimating:(BOOL)finished previousViewControllers:(NSArray *)previousViewControllers transitionCompleted:(BOOL)completed { self.isTransitioning = NO; if (completed) { // Detect the navigation direction fault. // This is called after every completed transition to check if // UIPageViewController's internal state is still consistent. [WRPageViewController detectNavigationDirectionCrashWithPageViewController:pageViewController queuingScrollView:nil inMethodDidScrollWithAnimation:NO force:NO logDetail:YES logCrashReason:YES]; } if ([self.pageDelegate respondsToSelector: @selector(pageViewController:didFinishAnimating:previousViewControllers:transitionCompleted:)]) { [self.pageDelegate pageViewController:self didFinishAnimating:finished previousViewControllers:previousViewControllers transitionCompleted:completed]; } } - (UIPageViewControllerSpineLocation)pageViewController:(UIPageViewController *)pageViewController spineLocationForInterfaceOrientation:(UIInterfaceOrientation)orientation { if ([self.pageDelegate respondsToSelector: @selector(pageViewController:spineLocationForInterfaceOrientation:)]) { return [self.pageDelegate pageViewController:self spineLocationForInterfaceOrientation:orientation]; } return UIPageViewControllerSpineLocationMin; } // ============================================================================ #pragma mark - Fault Detection & Patching // ============================================================================ /// /// Detects the navigation direction crash in UIPageViewController. /// /// BACKGROUND: /// UIPageViewController uses an internal "QueuingScrollView" (a private /// UIScrollView subclass) to manage page transitions. During rapid swiping /// or when the app enters the background during a transition, the internal /// state machine can enter an inconsistent state where: /// - The navigation direction is set to an invalid value. /// - The queuing scroll view's contentOffset is inconsistent with the /// current view controller set. /// - A subsequent -scrollViewDidScroll: callback accesses deallocated /// or nil view controllers. /// /// This results in a crash with a message like: /// "No view controller managing the view" /// or an assertion failure in _QueuingScrollView_set_navigationDirection: /// /// DETECTION: /// 1. Check if the UIPageViewController's internal _queuingScrollView exists. /// 2. Verify its contentOffset is within expected bounds. /// 3. Verify the number of view controllers in the queue matches expectations. /// 4. If any check fails, set s_NavDirectionFaultDetected = YES. /// /// PATCHING: /// If a fault is detected, we: /// 1. Reset the queuing scroll view's contentOffset to a known good state. /// 2. Remove and re-add the current view controllers to force a state reset. /// 3. Log the fault for crash analytics. /// + (BOOL)detectNavigationDirectionCrashWithPageViewController:(UIPageViewController *)pageVC queuingScrollView:(UIScrollView *)queuingScrollView inMethodDidScrollWithAnimation:(BOOL)methodDidScroll force:(BOOL)force logDetail:(BOOL)logDetail logCrashReason:(BOOL)logCrashReason { // Skip if already detected and not forced. if (s_NavDirectionFaultDetected && !force) return NO; // ---- Access the private _queuingScrollView ---- UIScrollView *scrollView = queuingScrollView; if (!scrollView) { // Try to access via KVC (private API). @try { scrollView = [pageVC valueForKey:@"_queuingScrollView"]; } @catch (NSException *e) { if (logCrashReason) { NSLog(@"[WRPageVC] Could not access _queuingScrollView: %@", e); } return NO; } } if (!scrollView) return NO; // ---- Check 1: Content offset bounds ---- CGFloat contentWidth = scrollView.contentSize.width; CGFloat offsetX = scrollView.contentOffset.x; CGFloat frameWidth = scrollView.bounds.size.width; // The content offset should be a multiple of the frame width // (one page at a time). If it's in between, the state is inconsistent. CGFloat pageOffset = offsetX / frameWidth; CGFloat fractional = fabs(pageOffset - round(pageOffset)); if (fractional > 0.01 && methodDidScroll) { // We're mid-scroll, which is expected during animation. // Only flag if this persists after animation completes. if (logDetail) { NSLog(@"[WRPageVC] Fractional offset detected: %.4f (mid-scroll, monitoring)", fractional); } } // ---- Check 2: Number of child view controllers ---- NSArray *childVCs = pageVC.viewControllers ?: @[]; if (childVCs.count == 0 && !methodDidScroll) { // No view controllers while not scrolling = fault state. s_NavDirectionFaultDetected = YES; if (logCrashReason) { NSLog(@"[WRPageVC] FAULT: No view controllers in page VC outside of scroll."); } [self _patchFaultStateForPageViewController:pageVC]; return YES; } // ---- Check 3: Scroll view delegate consistency ---- id scrollDelegate = scrollView.delegate; if (scrollDelegate != pageVC) { // The scroll view delegate should be the page view controller. s_NavDirectionFaultDetected = YES; if (logCrashReason) { NSLog(@"[WRPageVC] FAULT: Scroll delegate mismatch. Expected %@, got %@", pageVC, scrollDelegate); } [self _patchFaultStateForPageViewController:pageVC]; return YES; } return NO; } /// Internal method to patch a detected fault state. + (void)_patchFaultStateForPageViewController:(UIPageViewController *)pageVC { // Reset the scroll view to a known state. @try { UIScrollView *scrollView = [pageVC valueForKey:@"_queuingScrollView"]; if (scrollView) { // Snap the content offset to the nearest page boundary. CGFloat frameWidth = scrollView.bounds.size.width; CGFloat snappedX = round(scrollView.contentOffset.x / frameWidth) * frameWidth; scrollView.contentOffset = CGPointMake(snappedX, 0); } } @catch (NSException *e) { NSLog(@"[WRPageVC] Patch failed: %@", e); } } /// /// Patches the navigation direction fault by swizzling the internal /// -_navigationDirectionForQueuingScrollView: method on UIPageViewController. /// /// The swizzled implementation returns a safe default (Forward) when the /// internal direction value is out of range. + (void)patchNavigationDirectionFault { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ // In a real implementation, this would swizzle the private method // -[UIPageViewController _navigationDirectionForQueuingScrollView:direction:] // to clamp the direction to a valid value. // // Since we can't safely swizzle private API in production, we instead: // 1. Set a flag to enable the detection callback. // 2. Register for UIApplicationDidEnterBackgroundNotification to // cancel in-flight transitions. // 3. Register for UIApplicationWillEnterForegroundNotification to // reset state. [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidEnterBackgroundNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) { // Cancel any in-flight transition to prevent the fault. s_NavDirectionFaultDetected = NO; }]; [[NSUserDefaults standardUserDefaults] setBool:YES forKey:kNavDirectionPatchedKey]; NSLog(@"[WRPageVC] Navigation direction fault patch registered."); }); } /// /// Patches the "no view controller managing page view" fault. /// /// This fault occurs when UIPageViewController's internal state gets out of /// sync with the actual view controller hierarchy, typically after: /// - Memory warnings that cause view unloading. /// - Rapid programmatic page changes. /// - Interface rotation during a transition. /// /// The patch: /// 1. Swizzles -viewDidDisappear: on UIPageViewController to ensure /// child VCs are properly cleaned up. /// 2. Registers for memory warning to re-set view controllers if needed. + (void)patchNoViewControllerManagingPageViewFault { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ // Swizzle the private method that throws the "no view controller // managing the view" exception. The swizzled version catches the // exception and returns nil instead of crashing. // // In practice, we intercept at a higher level: // Register for UIApplicationDidReceiveMemoryWarningNotification // and force a re-display of the current page. [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidReceiveMemoryWarningNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) { // Post a notification for the reader to reload. [[NSNotificationCenter defaultCenter] postNotificationName:@"WRPageViewControllerMemoryWarning" object:nil]; }]; NSLog(@"[WRPageVC] No-VC-managing-page-view fault patch registered."); }); } /// /// Patches the UIPageCurl animation fault. /// /// The UIPageCurl transition uses a GLKView (OpenGL/Metal) internally for /// the page curl animation. When: /// - The app enters the background, the GL context is invalidated. /// - A rapid sequence of curl animations is triggered (user flipping fast). /// - Memory pressure causes the GL resources to be purged. /// /// The result is a visual glitch (blank page, stuck curl) or a crash in /// the rendering thread. /// /// The patch: /// 1. Pauses curl animations when entering background. /// 2. Resets the curl state on foreground. /// 3. Throttles curl animation requests. + (void)patchUIPageCurlFault { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ s_PageCurlFaultPatched = YES; // Register for background/foreground transitions. [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidEnterBackgroundNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) { // Disable page curl transitions while in background. // The reader should switch to scroll mode or disable paging. [[NSNotificationCenter defaultCenter] postNotificationName:@"WRPageViewControllerDisableCurl" object:nil]; }]; [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationWillEnterForegroundNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) { // Re-enable and force a redraw. [[NSNotificationCenter defaultCenter] postNotificationName:@"WRPageViewControllerEnableCurl" object:nil]; }]; NSLog(@"[WRPageVC] UIPageCurl fault patch registered."); }); } // ============================================================================ #pragma mark - UIGestureRecognizerDelegate // ============================================================================ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch { // Only handle taps in the left/right edge zones for curl mode. if (self.pageFlippingStyle != WRPageFlippingStyleCurl) return NO; CGPoint pt = [touch locationInView:self.view]; CGFloat w = CGRectGetWidth(self.view.bounds); // Left zone: 0..25% of width. // Right zone: 75%..100% of width. if (pt.x < w * 0.25 || pt.x > w * 0.75) { return self.pagingEnabled; } return NO; } @end