You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何使用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

代码问题分析

  1. 滚动捕捉逻辑冲突:CSS中overflow: -webkit-paged-x是旧版WebKit分页属性,和现代scroll-snap机制不兼容,会导致滚动捕捉失效。
  2. WKWebView滚动拦截无效:直接重写WKWebView的adjustScroll等方法无法生效,因为WKWebView的滚动由内部NSScrollView控制,需要通过NSScrollView代理处理。
  3. 同步JS执行阻塞主线程:自定义的stringByEvaluatingJavaScriptFromString用RunLoop阻塞等待结果,会导致界面卡顿,违反macOS主线程响应规范。
  4. 触控事件覆盖不全:仅处理双指手势,未处理单指滑动(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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.17 07:59:54