如何使用WKWebView实现macOS平台电子书翻页功能?
问题
Apple已弃用功能完善的WebView,改用功能精简的WKWebView,但缺少合适的电子书翻页工具,我希望开发可公开的GitHub工具。当前代码在macOS上点击按钮可正常逐页滚动电子书,但鼠标/触控板滑动时,加速度效应会导致页面滚动过度,无法像按钮操作那样精准停在单页。现有代码如下:
// // ReaderWindowController.m // #import "ReaderWindowController.h" #import "LibrarianFormatPluginInterface.h" #define WindowSideLeft 0 #define WindowSideRight 1 @interface WKWebView (SynchronousEvaluateJavaScript) - (NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script; @end @implementation WKWebView (SynchronousEvaluateJavaScript) - (NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script { __block NSString *resultString = nil; __block BOOL finished = NO; [self evaluateJavaScript:script completionHandler:^(id result, NSError *error) { if (error == nil) { if (result != nil) { resultString = [NSString stringWithFormat:@"%@", result]; } } else { NSLog(@"evaluateJavaScript error : %@", error.localizedDescription); } finished = YES; }]; while (!finished) { [NSRunLoop.currentRunLoop runMode:NSDefaultRunLoopMode beforeDate:NSDate.distantFuture]; } return resultString; } - (void)scrollWheel:(NSEvent *)event { NSLog(@"Scrolled wheel"); } - (BOOL)scrollRectToVisible:(NSRect)rect { NSLog(@"Scroll rect to visible %f x %f , %f x %f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height); return true; } - (void)scrollClipView:(NSClipView *)clipView toPoint:(NSPoint)point { NSLog(@"Scroll clip view %@ to point %f x %f", clipView, point.x, point.y); } - (NSRect)adjustScroll:(NSRect)newVisible { NSRect modifiedRect=newVisible; // snap to 72 pixel increments modifiedRect.origin.x = (int)(modifiedRect.origin.x/72.0) * 72.0; // modifiedRect.origin.y = (int)(modifiedRect.origin.y/72.0) * 72.0; // return the modified rectangle return modifiedRect; } - (void)scrollRangeToVisible:(NSRange)range { NSLog(@"Scroll range to visible"); } - (void)scrollPoint:(NSPoint)point { NSLog(@"Scroll point to visible"); } - (void)reflectScrolledClipView:(NSClipView *)clipView { NSLog(@"reflectScrolledClipView point to visible"); } @end @interface NSView ( TouchEvents ) @end @implementation NSView ( TouchEvents ) float beginX, endX; - (void)touchesBeganWithEvent:(NSEvent *)event { if(event.type == NSEventTypeGesture){ NSSet *touches = [event touchesMatchingPhase:NSTouchPhaseAny inView:self]; if(touches.count == 2){ for (NSTouch *touch in touches) { beginX = touch.normalizedPosition.x; } } } } - (void)touchesEndedWithEvent:(NSEvent *)event { if(event.type == NSEventTypeGesture){ NSSet *touches = [event touchesMatchingPhase:NSTouchPhaseAny inView:self]; NSDictionary* userInfo; if(touches.count == 2){ for (NSTouch *touch in touches) { endX = touch.normalizedPosition.x; } // since there are two touches, endX will always end up with the data from the second touch if (endX > beginX) { NSLog(@"swipe right!"); userInfo = @{@"direction": @(WindowSideRight)}; } else if (endX < beginX) { NSLog(@"swipe left!"); userInfo = @{@"direction": @(WindowSideLeft)}; } else { NSLog(@"no swipe!"); } [NSNotificationCenter.defaultCenter postNotificationName:@"pageScrollEvent" object:nil userInfo:userInfo]; } } } - (void)scrollWheel:(NSEvent *)event { NSLog(@"user scrolled %f horizontally and %f vertically", [event deltaX], [event deltaY]); } @end @interface ReaderWindowController () @end @implementation ReaderWindowController - (void)createButtonOnSide:(int)side withSelector:(SEL)aSelector { int x = 0, y = 100, width = 40, height = 230; NSRect framesize = NSMakeRect(x, y, width, height); NSString* label = side==WindowSideLeft?@"<":@">"; NSButton *myButton = [NSButton.alloc initWithFrame:CGRectZero]; [myButton setButtonType:NSButtonTypeMomentaryPushIn]; if (@available(macOS 11.0, *)) { NSImage* arrow = side==WindowSideLeft?[NSImage imageWithSystemSymbolName:@"arrowshape.left.fill" accessibilityDescription:label]:[NSImage imageWithSystemSymbolName:@"arrowshape.right.fill" accessibilityDescription:label]; [myButton setImage:arrow]; } else { [myButton setTitle:label]; } [myButton setBezelStyle:NSBezelStyleTexturedSquare]; [myButton setTarget:self]; [myButton setAction:aSelector]; [myButton setTag:side]; myButton.translatesAutoresizingMaskIntoConstraints = false; [self.window.contentView addSubview:myButton]; [myButton.widthAnchor constraintEqualToConstant:framesize.size.width].active = YES; [myButton.heightAnchor constraintEqualToConstant:framesize.size.height].active = YES; [myButton.centerYAnchor constraintEqualToAnchor:self.window.contentView.centerYAnchor].active = YES; if (side == WindowSideLeft) { [myButton.leadingAnchor constraintEqualToAnchor:self.window.contentView.leadingAnchor constant:0].active = YES; } else { [myButton.trailingAnchor constraintEqualToAnchor:self.window.contentView.trailingAnchor constant:0].active = YES; } NSTrackingArea* trackingArea = [NSTrackingArea.alloc initWithRect:myButton.bounds options: NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways owner:self userInfo:nil]; [self.window.contentView addTrackingArea:trackingArea]; } - (void)mouseEntered:(NSEvent *)theEvent{ NSLog(@"entered"); } - (void)mouseExited:(NSEvent *)theEvent{ NSLog(@"exited"); } - (void)windowDidLoad { [super windowDidLoad]; [self.window setDelegate:self]; [bookPages setAllowedTouchTypes:(NSTouchTypeMaskDirect | NSTouchTypeMaskIndirect)]; [self.window setAppearance:[NSAppearance appearanceNamed:NSAppearanceNameAqua]]; [bookPages setNavigationDelegate:self]; pageCount = 0; // might want to load this from preferences [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(loadDidFinish:) name:@"LoadDidFinishNotification" object:nil]; [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(buttonPressed:) name:@"PageScrollEvent" object:nil]; [self createButtonOnSide:WindowSideLeft withSelector:@selector(buttonPressed:)]; [self createButtonOnSide:WindowSideRight withSelector:@selector(buttonPressed:)]; } - (id)initWithBookPlugin:(id)bookPlug andWindowController:(NSNibName)windowNibName { if (bookPlug && ![[bookPlug className] isEqualToString:[NSNull className]] && (self = [super initWithWindowNibName:windowNibName])) { bookPlugin = bookPlug; } return self; } - (void)loadDidFinish:(NSNotification*)notification { NSURLRequest* thisRequest = [bookPlugin getURLRequestForIndex:8]; [bookPages loadRequest:thisRequest]; } - (void)windowWillClose:(NSNotification *)notification { [NSNotificationCenter.defaultCenter removeObserver:self]; if (bookPlugin) { bookPlugin = nil; } } -(void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { NSString *cssString = @"body { overflow: -webkit-paged-x !important; direction: ltr !important; -webkit-overflow-scrolling: touch; scroll-snap-type: x mandatory; scroll-snap-align: center; }"; NSString *javascriptString = @"var style = document.createElement('style'); style.innerHTML = '%@'; document.head.appendChild(style)"; NSString *javascriptWithCSSString = [NSString stringWithFormat:javascriptString, cssString]; [webView evaluateJavaScript:javascriptWithCSSString completionHandler:nil]; } -(NSSize)getViewDimensionsForwebView:(WKWebView *)webView { NSString* width = [webView stringByEvaluatingJavaScriptFromString:@"Math.max( document.body.scrollWidth, document.body.offsetWidth, document.documentElement.clientWidth, document.documentElement.scrollWidth, document.documentElement.offsetWidth )"]; NSString* height = [webView stringByEvaluatingJavaScriptFromString:@"Math.max( document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight )"]; return NSMakeSize(width.floatValue,height.floatValue); } - (void) updateAfterDelay:(id)sender { [self buttonPressed:nil]; } - (void)buttonPressed:(id)sender { if ([[sender className] isEqualToString:@"NSButton"]) { if ([sender tag] == WindowSideLeft) { pageCount--; } else { pageCount++; } } else if ([[sender className] isEqualToString:@"NSConcreteNotification"]) { if ([[sender userInfo][@"direction"] isEqualTo: @(WindowSideLeft)]) { pageCount--; } else { pageCount++; } } pageCount = pageCount<0?0:pageCount; NSInteger pageWidth = self.window.contentView.frame.size.width; NSString* jsString = [NSString stringWithFormat:@"window.scrollTo({top: 0, left: %ld, behavior: \"smooth\",});", pageWidth * pageCount]; [bookPages evaluateJavaScript:jsString completionHandler:nil]; if (sender != nil) { [self performSelector:@selector(updateAfterDelay:) withObject:nil afterDelay:0.75]; } } @end
代码问题分析
- 滚动捕捉逻辑冲突:CSS中
overflow: -webkit-paged-x是旧版WebKit分页属性,和现代scroll-snap机制不兼容,会导致滚动捕捉失效。 - WKWebView滚动拦截无效:直接重写WKWebView的
adjustScroll等方法无法生效,因为WKWebView的滚动由内部NSScrollView控制,需要通过NSScrollView代理处理。 - 同步JS执行阻塞主线程:自定义的
stringByEvaluatingJavaScriptFromString用RunLoop阻塞等待结果,会导致界面卡顿,违反macOS主线程响应规范。 - 触控事件覆盖不全:仅处理双指手势,未处理单指滑动(scrollWheel事件)的分页逻辑,且全局变量
beginX/endX存在线程安全问题。
代码修复建议
1. 修正CSS滚动捕捉逻辑
替换原CSS,移除旧分页属性,确保scroll-snap正常工作:
body { direction: ltr !important; overflow-x: scroll; overflow-y: hidden; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; } /* 给每个页面容器添加滚动锚点 */ .page-container { scroll-snap-align: start; }
2. 拦截内部滚动视图实现精准分页
在webView:didFinishNavigation:中获取WKWebView的内部滚动视图并设置代理:
-(void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { // ... 原有CSS注入代码 ... NSScrollView *scrollView = webView.enclosingScrollView; scrollView.delegate = self; }
实现NSScrollViewDelegate方法,滚动结束后自动对齐到最近页面:
- (void)scrollViewDidEndScrollingAnimation:(NSScrollView *)scrollView { [self snapToNearestPage]; } - (void)scrollViewDidEndLiveScroll:(NSScrollView *)scrollView { [self snapToNearestPage]; } - (void)snapToNearestPage { CGFloat pageWidth = self.window.contentView.frame.size.width; CGFloat currentOffset = bookPages.enclosingScrollView.contentView.bounds.origin.x; NSInteger targetPage = round(currentOffset / pageWidth); targetPage = MAX(0, targetPage); pageCount = targetPage; NSString* jsString = [NSString stringWithFormat:@"window.scrollTo({top: 0, left: %ld, behavior: \"instant\"});", (long)(pageWidth * pageCount)]; [bookPages evaluateJavaScript:jsString completionHandler:nil]; }
3. 替换同步JS执行
将getViewDimensionsForwebView:改为异步实现,避免阻塞主线程:
- (void)getViewDimensionsForWebView:(WKWebView *)webView completion:(void(^)(NSSize size))completion { [webView evaluateJavaScript:@"Math.max(document.body.scrollWidth, document.body.offsetWidth, document.documentElement.clientWidth, document.documentElement.scrollWidth, document.documentElement.offsetWidth)" completionHandler:^(id widthResult, NSError *widthError) { [webView evaluateJavaScript:@"Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight)" completionHandler:^(id heightResult, NSError *heightError) { CGFloat width = [widthResult floatValue]; CGFloat height = [heightResult floatValue]; if (completion) { completion(NSMakeSize(width, height)); } }]; }]; }
推荐开源项目
- FBReader:基于WKWebView的多格式电子书阅读器,包含完整的精准翻页和滚动捕捉逻辑,无废弃API。
- Kitabu:专注EPUB格式的macOS阅读器,采用现代WebKit API,代码结构清晰,适合参考翻页实现。
- BookReader:轻量级WKWebView阅读器,支持自定义翻页动画和滚动对齐,代码简洁易扩展。
内容的提问来源于stack exchange,提问作者headbanger
相关产品推荐
相关产品推荐

