在NSPopover中使用NSNumberFormatter

13

有没有办法让NSNumberFormatter(或者可能是任何其他NSFormatter)在NSPopover中工作?

在popover中,NSTextField的值与NSViewController的representedObject绑定。当无效数字输入到字段中时(例如,“asdf”)会出现一个Sheet,显示该值无效,并呈现在包含弹出窗口的NSView所在的NSWindow中。

一旦你点击“确定”,就会得到以下回溯:

* thread #1: tid = 0x4e666a, 0x00007fff931f9097 libobjc.A.dylib`objc_msgSend + 23, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=EXC_I386_GPFLT)
frame #0: 0x00007fff931f9097 libobjc.A.dylib`objc_msgSend + 23
frame #1: 0x00007fff8a1fa6c8 AppKit`-[NSTextView(NSSharing) becomeKeyWindow] + 106
frame #2: 0x00007fff8a080941 AppKit`-[NSWindow(NSWindow_Theme) acquireKeyAppearance] + 207
frame #3: 0x00007fff8a0800df AppKit`-[NSWindow becomeKeyWindow] + 1420
frame #4: 0x00007fff8a07f5c6 AppKit`-[NSWindow _changeKeyAndMainLimitedOK:] + 803
frame #5: 0x00007fff8a1a205d AppKit`-[NSWindow _orderOutAndCalcKeyWithCounter:stillVisible:docWindow:] + 1156
frame #6: 0x00007fff8a0876c5 AppKit`-[NSWindow _reallyDoOrderWindow:relativeTo:findKey:forCounter:force:isModal:] + 3123
frame #7: 0x00007fff8a0867f0 AppKit`-[NSWindow _doOrderWindow:relativeTo:findKey:forCounter:force:isModal:] + 786
frame #8: 0x00007fff8a086470 AppKit`-[NSWindow orderWindow:relativeTo:] + 162
frame #9: 0x00007fff8a1a1425 AppKit`__18-[NSWindow _close]_block_invoke + 443
frame #10: 0x00007fff8a1a1230 AppKit`-[NSWindow _close] + 370
frame #11: 0x00007fff8a2d0565 AppKit`__106-[NSApplication(NSErrorPresentation) presentError:modalForWindow:delegate:didPresentSelector:contextInfo:]_block_invoke3221 + 50
frame #12: 0x00007fff8a2d02f7 AppKit`-[NSApplication(NSErrorPresentation) _something:wasPresentedWithResult:soContinue:] + 18
frame #13: 0x00007fff8a28fe9d AppKit`-[NSAlert didEndAlert:returnCode:contextInfo:] + 90
frame #14: 0x00007fff8a28f8c2 AppKit`-[NSWindow endSheet:returnCode:] + 368
frame #15: 0x00007fff8a28f49d AppKit`-[NSAlert buttonPressed:] + 107
frame #16: 0x00007fff8a1543d0 AppKit`-[NSApplication sendAction:to:from:] + 327
frame #17: 0x00007fff8a15424e AppKit`-[NSControl sendAction:to:] + 86
frame #18: 0x00007fff8a1a0d7d AppKit`-[NSCell _sendActionFrom:] + 128
frame #19: 0x00007fff8a1ba715 AppKit`-[NSCell trackMouse:inRect:ofView:untilMouseUp:] + 2316
frame #20: 0x00007fff8a1b9ae7 AppKit`-[NSButtonCell trackMouse:inRect:ofView:untilMouseUp:] + 487
frame #21: 0x00007fff8a1b91fd AppKit`-[NSControl mouseDown:] + 706
frame #22: 0x00007fff8a13ad08 AppKit`-[NSWindow sendEvent:] + 11296
frame #23: 0x00007fff8a0d9744 AppKit`-[NSApplication sendEvent:] + 2021
frame #24: 0x00007fff89f29a29 AppKit`-[NSApplication run] + 646
frame #25: 0x00007fff89f14803 AppKit`NSApplicationMain + 940

发生objc_msgSend崩溃时的寄存器如下:
(lldb) reg read
General Purpose Registers:
   rax = 0x0000610000190740
   rbx = 0x0000610000190740
   rcx = 0x0000000000000080
   rdx = 0x00007fff8a97fd93  "currentEditor"
   rdi = 0x0000610000190740
   rsi = 0x00007fff8a9612bf  "respondsToSelector:"
   rbp = 0x00007fff5fbfeae0
   rsp = 0x00007fff5fbfeab8
    r8 = 0x000000000000002e
    r9 = 0xffff9fffffeb1bbf
   r10 = 0x00007fff8a9612bf  "respondsToSelector:"
   r11 = 0xbaddbe5c3e96bead
   r12 = 0x0000610000053830
   r13 = 0x00007fff931f9080  libobjc.A.dylib`objc_msgSend
   r14 = 0x000060000012a500
   r15 = 0x00007fff931f9080  libobjc.A.dylib`objc_msgSend
   rip = 0x00007fff931f9097  libobjc.A.dylib`objc_msgSend + 23
rflags = 0x0000000000010246
    cs = 0x000000000000002b
    fs = 0x0000000000000000
    gs = 0x00000000c0100000

我猜测这是因为短暂的弹出窗口在显示表单后消失了,所以当前编辑器和任何可以响应选择器的对象都无法使用。
将弹出行为设置为NSPopoverBehaviorSemitransient会有所帮助,但如果文本字段中放入无效值导致弹出窗口被关闭,则仍会抛出异常。
此时,我能想到的避免这个问题的方法就是手动验证数值。很烦人。
更新1
正如Brian Webster在下面发现的那样,这是AppKit的一个根本性问题。
由于我的验证需求非常简单(只需要正整数),所以解决方法是在NSPopover中使用的KVC对象中进行手动验证。由于NSTextField确实想要使用字符串值,所以使用-valueForKey:和-setValue:forKey:来转换标量值。当你为文本字段绑定的值打开“立即验证”时,每次文本字段更改时都会调用验证方法。
(在你问之前,NSValueTransformer不能完成这项工作,因为它不参与验证过程。它只在填充字段或保存更改时被调用。我想要用户输入一些无效数据后立即得到反馈——就像NSFormatter那样。)
以下是我所做的主要内容:
- (id)valueForKey:(NSString *)key
{
    if ([key isEqualToString:@"property1"]) {
        return [NSString stringWithFormat:@"%zd", _property1];
    }
    else if ([key isEqualToString:@"property2"]) {
        return [NSString stringWithFormat:@"%zd", _property2];
    }
    else {
        return [super valueForKey:key];
    }
}


- (BOOL)validateValue:(inout id *)ioValue forKey:(NSString *)inKey error:(out NSError **)outError
{
    if (! *ioValue) {
        *ioValue = @"0";
    }
    else if ([*ioValue isKindOfClass:[NSString class]]) {
        NSString *inputString = [[(NSString *)*ioValue copy] autorelease];
        inputString = [inputString stringByReplacingOccurrencesOfString:@"," withString:@""];
        NSInteger integerValue = [inputString integerValue];
        if (integerValue < 0) {
            integerValue = -integerValue;
        }
        *ioValue = [NSString stringWithFormat:@"%zd", integerValue];
    }

    return YES;
}

- (void)setValue:(id)value forKey:(NSString *)key
{
    if ([value isKindOfClass:[NSString class]]) {
        if ([key isEqualToString:@"property1"]) {
            _property1 = [value integerValue];
        }
        else if ([key isEqualToString:@"property2"]) {
            _property2 = [value integerValue];
        }
        else {
            [super setValue:value forKey:key];
        }
    }
    else {
        [super setValue:value forKey:key];
    }
}

现在我需要洗个澡。

更新2

感谢@PixelCutCompany提供的一些有用提示,关于他们在PaintCode应用程序中如何处理事情:

https://twitter.com/PixelCutCompany/status/441695942774104064 https://twitter.com/PixelCutCompany/status/441696198140125184

我想到了这个:

@interface PopupNumberFormatter : NSNumberFormatter

@end

@implementation PopupNumberFormatter

- (BOOL)getObjectValue:(out id *)anObject forString:(NSString *)aString range:(inout NSRange *)rangep error:(out NSError **)error
{
    NSNumber *minimum = [self minimum];
    NSNumber *maximum = [self maximum];

    if (aString == nil || [aString length] == 0) {
        if (minimum) {
            *anObject = minimum;
        }
        else if (maximum) {
            *anObject = maximum;
        }
        else {
            *anObject = [NSNumber numberWithInteger:0];
        }
    }
    else {
    if (! [super getObjectValue:anObject forString:aString range:rangep error:nil]) {
        // if the superclass can't parse the string, assign a reasonable default
        if (minimum) {
            *anObject = minimum;
        }
        else if (maximum) {
            *anObject = maximum;
        }
        else {
            *anObject = [NSNumber numberWithInteger:0];
        }
    }
    else {
        // clamp the parsed value to a minimum and maximum (if set)
        if (minimum && [*anObject compare:minimum] == NSOrderedAscending) {
            *anObject = minimum;
        }
        else if (maximum && [*anObject compare:maximum] == NSOrderedDescending) {
            *anObject = maximum;
        }
    }
    }

    return YES;
}

@end

基本上,您可以通过始终提供有效值来避免表格或对话框中的问题。上述代码在分配默认值时考虑了最小和最大值。子类还考虑了nil或空字符串以及夹紧值。
这让我感觉不那么糟糕了。
3个回答

11

我设置了一个测试项目来验证是否能复现这个问题,结果出现了相同的行为。以下是似乎发生的事件顺序:

  1. 当您在文本字段中按Enter键时,会触发绑定,该绑定尝试通过 NSNumberFormatter 验证字段中的值。
  2. 当验证失败时,绑定系统通过响应链呈现一个 NSError 对象。这会冒泡到 NSApplication,后者会将错误作为窗口上的一个表单呈现。
  3. 表单的出现会触发弹出窗口关闭,进而再次触发相同的绑定,试图显示另一个错误。但是,由于已经在窗口上显示了一个表单,第二个错误永远不会被显示。不过,如果您更改绑定选项并启用“始终呈现应用程序模态警报”,则会在两个单独的警报窗口中显示两个分离的警报。

我认为是这个内部错误导致 AppKit 出现问题,在处理字段编辑器(即堆栈跟踪中的 NSTextView)时,它最终向已释放的 NSTextField 发送消息。

我找到的最佳解决方法是在我用来控制弹出窗口的 NSViewController 子类中实现 -willPresentError: 方法,如下:

- (NSError *)willPresentError:(NSError *)error
{
    NSMutableDictionary* userInfo = [[error userInfo] mutableCopy];

    [self.numberTextField unbind:@"value"];
    [userInfo setValue:nil forKey:NSRecoveryAttempterErrorKey];
    [userInfo setValue:nil forKey:NSLocalizedRecoveryOptionsErrorKey];
    return [NSError errorWithDomain:[error domain] code:[error code] userInfo:userInfo];
}

unbind:方法会移除绑定,这样当弹出窗口关闭时就不会再尝试重新验证文本字段。由于假设每次显示弹出窗口都是从头开始创建而不是重用,因此这应该不会有任何不良影响。

另外,由于“确定”和“放弃更改”按钮在它们所指的字段消失后已经没有意义了,我在将错误传递给AppKit进行显示之前,从错误中删除了绑定系统的恢复尝试器。这样,它只会显示“值X无效”的消息,带有一个“确定”按钮,但该按钮仅起到关闭错误窗口的作用。

请注意,这仅在启用了“始终显示应用程序模态警报”绑定时才有效。否则,如果AppKit将要将错误显示为工作表,则似乎不会在视图控制器上调用willPresentError:方法。如果您想保留工作表行为,可以在响应者链的其他位置(例如主窗口的控制器)插入逻辑。

至于这是否比手动验证值更丑陋,就留给您自己决定吧。 :)


没错,我肯定会把这个归类为AppKit的一个bug。我有机会的话会在radar上提交我的示例项目。 - Brian Webster
请在OpenRadar上提交错误报告,并告诉我编号:我一定想复制这个BUG(感谢您抽出时间创建样例项目!) - chockenberry

3

首先,设置弹出窗口的代理:

[ popover setDelegate: myDelegate];

在代理中实现popoverShouldClose:方法如下。其思路是,“立即验证”的控件会拒绝放弃第一响应者状态,直到用户提供有效值为止。
- ( BOOL) popoverShouldClose: ( NSPopover*) popover {
    if( ![[[[ popover contentViewController] view] window] makeFirstResponder: popover]) {
        return NO;
    }

/*  // Using commitEditing also solves the problem. However if user chooses
    // "Discard Changes" during immediate validation, the commitEditing returns YES,
    // and the result of discarding is not visible, because popover is closed.
    if( ![[ popover contentViewController] commitEditing])  {
        return NO;
    }
*/
    // return YES or NO depending on other considerations you may have
    return YES;
}

这在我使用的OS X 10.8中有效,使用了弹出行为NSPopoverBehaviorSemitransient和NSPopoverBehaviorTransient。您可能需要在更高版本的操作系统上进行测试。


好主意。在10.13上对我有用。验证错误表单从弹出窗口的父级(从按钮弹出)中弹出,但对我的使用来说这是可以的。 - Paul Collins

1
同样的问题也会出现在由Core Data模型对象引起的验证错误中。另一种方法是替换系统提供的模态对话框,并在现有的弹出窗口中使用弹出窗口来呈现错误:

example of an error presented in a popover

可以通过在主弹出窗口的内容视图控制器中覆盖-[NSResponder presentError: modalForWindow: delegate: didPresentSelector: contextInfo:]来实现。我不会说它是百分百可靠的,但以下代码可以很好地呈现错误弹出窗口:

- (void)presentError:(NSError *)error modalForWindow:(NSWindow *)window delegate:(id)delegate didPresentSelector:(SEL)didPresentSelector contextInfo:(void *)contextInfo {

self.validationErrorPopover.contentViewController = [[ZBErrorViewController alloc] initWithError:error];

NSView *sourceView;
if ([self.view.window.firstResponder isKindOfClass:[NSText class]]) // i.e., current field editor
    sourceView = (NSText*)self.view.window.firstResponder;
else
    sourceView = self.view;

[self.validationErrorPopover showRelativeToRect:[self.view convertRect:sourceView.bounds fromView:sourceView] ofView:self.view preferredEdge:NSMaxYEdge];
}

在上面的示例中,self.validationErrorPopover只是一个配置了瞬态行为和HUD外观的NSPopover,ZBErrorViewController是一个普通的NSViewController,其中添加了一个属性来保存NSError对象,它的视图包含一个文本字段,绑定到错误的localizedDescription。简单的自动布局约束确保错误弹出窗口的大小适当。
这只是一项初步的工作,我相信可以进一步改进。例如,通过逻辑呈现错误的失败原因并调用恢复尝试者(我放弃了...普通的撤消功能允许用户恢复到原始值)。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接