From 69eb863dec21dbd85d8569966deb2eef8b3a030f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Sun, 2 Aug 2026 11:16:05 +0200 Subject: [PATCH 1/3] feat: Add glass input support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Müller --- Source/SLKTextInputbar.h | 13 +++ Source/SLKTextInputbar.m | 78 +++++++++++---- Source/SLKTextViewController.h | 8 ++ Source/SLKTextViewController.m | 150 +++++++++++++++++++++++++---- Source/UIScrollView+SLKAdditions.m | 15 ++- 5 files changed, 225 insertions(+), 39 deletions(-) diff --git a/Source/SLKTextInputbar.h b/Source/SLKTextInputbar.h index 41b5cf3a..f8255212 100644 --- a/Source/SLKTextInputbar.h +++ b/Source/SLKTextInputbar.h @@ -62,6 +62,9 @@ UIKIT_EXTERN NSString * const SLKTextInputbarContentSizeDidChangeNotification; /** The inner padding to use when laying out content in the view. Default is {5, 8, 5, 8}. */ @property (nonatomic, assign) UIEdgeInsets contentInset; +/** The minimum size of the left and right button, so they keep an accessible touch target even with a small image. Default is {44, 44}. */ +@property (nonatomic, assign) CGSize minimumButtonSize; + /** The minimum height based on the intrinsic content size's. */ @property (nonatomic, readonly) CGFloat minimumInputbarHeight; @@ -87,6 +90,16 @@ UIKIT_EXTERN NSString * const SLKTextInputbarContentSizeDidChangeNotification; - (instancetype)initWithTextViewClass:(Class)textViewClass withTypingIndicatorViewClass:(Class)typingIndicatorClass; +/** + Recalculates the size and position of the left and right button. + + Their image is observed to do this automatically, but that observation is bound to the imageView of the button + at the time it was set up. UIKit recreates that imageView when the button is given a UIButtonConfiguration, so + call this after changing the image of such a button. + */ +- (void)invalidateButtonSizes; + + #pragma mark - Text Editing ///------------------------------------------------ /// @name Text Editing diff --git a/Source/SLKTextInputbar.m b/Source/SLKTextInputbar.m index 51bfb44b..052430dd 100644 --- a/Source/SLKTextInputbar.m +++ b/Source/SLKTextInputbar.m @@ -19,8 +19,6 @@ NSString * const SLKTextInputbarDidMoveNotification = @"SLKTextInputbarDidMoveNotification"; NSString * const SLKTextInputbarContentSizeDidChangeNotification = @"SLKTextInputbarContentSizeDidChangeNotification"; -CGFloat const SLKTextInputbarMinButtonWidth = 44.0; -CGFloat const SLKTextInputbarMinButtonHeight = 44.0; CGFloat const SLKTextInputbarTypingIndicatorHeight = 24.0; @interface SLKTextInputbar () @@ -40,6 +38,7 @@ @interface SLKTextInputbar () @property (nonatomic, strong) NSLayoutConstraint *typingIndicatorViewHC; @property (nonatomic, strong) NSLayoutConstraint *typingIndicatorViewTextViewPaddingConstraint; @property (nonatomic, strong) NSArray *charCountLabelVCs; +@property (nonatomic, strong) NSArray *slk_layoutConstraints; @property (nonatomic, assign) UIEdgeInsets defaultInsets; @@ -50,6 +49,11 @@ @interface SLKTextInputbar () @property (nonatomic, strong) Class textViewClass; @property (nonatomic, strong) Class typingIndicatorClass; +// The objects we actually did register as an observer to. UIKit recreates these helper views for example when +// assigning a UIButtonConfiguration, so we can't rely on asking the buttons for them again when unregistering. +@property (nonatomic, weak) UIImageView *observedLeftButtonImageView; +@property (nonatomic, weak) UILabel *observedRightButtonTitleLabel; + @property (nonatomic, getter=isHidden) BOOL hidden; // Required override @end @@ -104,6 +108,7 @@ - (void)slk_commonInit self.autoHideRightButton = YES; self.editorContentViewHeight = 38.0; + self.minimumButtonSize = CGSizeMake(44.0, 44.0); self.defaultInsets = UIEdgeInsetsMake(5.0, 8.0, 5.0, 8.0); self.contentInset = _defaultInsets; @@ -127,9 +132,12 @@ - (void)slk_commonInit [self slk_registerNotifications]; + self.observedLeftButtonImageView = self.leftButton.imageView; + self.observedRightButtonTitleLabel = self.rightButton.titleLabel; + [self slk_registerTo:self.layer forSelector:@selector(position)]; - [self slk_registerTo:self.leftButton.imageView forSelector:@selector(image)]; - [self slk_registerTo:self.rightButton.titleLabel forSelector:@selector(font)]; + [self slk_registerTo:self.observedLeftButtonImageView forSelector:@selector(image)]; + [self slk_registerTo:self.observedRightButtonTitleLabel forSelector:@selector(font)]; self.accessibilityIdentifier = @"SLKTextInputbar"; } @@ -436,7 +444,7 @@ - (CGFloat)slk_appropriateRightButtonWidth } CGFloat width = [self.rightButton intrinsicContentSize].width; - width = (width >= SLKTextInputbarMinButtonWidth) ? width : SLKTextInputbarMinButtonWidth; + width = (width >= self.minimumButtonSize.width) ? width : self.minimumButtonSize.width; return width; } @@ -483,6 +491,18 @@ - (void)setAutoHideRightButton:(BOOL)hide [self layoutIfNeeded]; } +- (void)setMinimumButtonSize:(CGSize)minimumButtonSize +{ + if (CGSizeEqualToSize(self.minimumButtonSize, minimumButtonSize)) { + return; + } + + _minimumButtonSize = minimumButtonSize; + + [self slk_updateConstraintConstants]; + [self setNeedsLayout]; +} + - (void)setContentInset:(UIEdgeInsets)insets { if (UIEdgeInsetsEqualToEdgeInsets(self.contentInset, insets)) { @@ -495,9 +515,10 @@ - (void)setContentInset:(UIEdgeInsets)insets } _contentInset = insets; - - // Add new constraints - [self removeConstraints:self.constraints]; + + // Add new constraints. Only remove the constraints we created ourselves, others might have been added + // by the owner of this view (e.g. to position a background view behind the textView or the buttons). + [self removeConstraints:self.slk_layoutConstraints ? : self.constraints]; [self.editorContentView removeConstraints:self.editorContentView.constraints]; [self slk_setupViewConstraints]; [self setCounterPosition:_counterPosition]; @@ -569,6 +590,15 @@ - (void)setCounterPosition:(SLKCounterPosition)counterPosition } +#pragma mark - Button sizing + +- (void)invalidateButtonSizes +{ + [self slk_updateConstraintConstants]; + [self setNeedsLayout]; +} + + #pragma mark - Text Editing - (BOOL)canEditText:(NSString *)text @@ -701,6 +731,8 @@ - (void)slk_didChangeContentSizeCategory:(NSNotification *)notification - (void)slk_setupViewConstraints { + NSArray *foreignConstraints = self.constraints; + NSDictionary *metrics = @{ @"top" : @(self.contentInset.top), @"left" : @(self.contentInset.left), @@ -752,14 +784,22 @@ - (void)slk_setupViewConstraints self.leftButtonHC = [self slk_constraintForAttribute:NSLayoutAttributeHeight firstItem:self.leftButton secondItem:nil]; self.leftButtonBottomMarginC = [self slk_constraintForAttribute:NSLayoutAttributeBottom firstItem:self secondItem:self.leftButton]; - self.leftMarginWC = [[self slk_constraintsForAttribute:NSLayoutAttributeLeading] firstObject]; - self.rightButtonWC = [self slk_constraintForAttribute:NSLayoutAttributeWidth firstItem:self.rightButton secondItem:nil]; self.rightButtonHC = [self slk_constraintForAttribute:NSLayoutAttributeHeight firstItem:self.rightButton secondItem:nil]; - self.rightMarginWC = [[self slk_constraintsForAttribute:NSLayoutAttributeTrailing] firstObject]; - + self.rightButtonTopMarginC = [self slk_constraintForAttribute:NSLayoutAttributeTop firstItem:self.rightButton secondItem:self]; self.rightButtonBottomMarginC = [self slk_constraintForAttribute:NSLayoutAttributeBottom firstItem:self secondItem:self.rightButton]; + + // Remember the constraints we own, so -setContentInset: can rebuild them without touching foreign ones + NSMutableArray *layoutConstraints = [self.constraints mutableCopy]; + [layoutConstraints removeObjectsInArray:foreignConstraints]; + self.slk_layoutConstraints = layoutConstraints; + + // The margins are looked up by attribute only, so search our own constraints instead of all of them. + // Otherwise constraints added by the owner of this view (e.g. to position a background view behind the + // textView or the buttons) could be picked up here instead. + self.leftMarginWC = [[layoutConstraints filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"firstAttribute = %d", NSLayoutAttributeLeading]] firstObject]; + self.rightMarginWC = [[layoutConstraints filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"firstAttribute = %d", NSLayoutAttributeTrailing]] firstObject]; } - (void)slk_updateConstraintConstants @@ -796,9 +836,9 @@ - (void)slk_updateConstraintConstants CGSize rightButtonSize = [self.rightButton imageForState:self.rightButton.state].size; if (leftButtonSize.width > 0) { - leftButtonSize.width = (leftButtonSize.width >= SLKTextInputbarMinButtonWidth) ? leftButtonSize.width : SLKTextInputbarMinButtonWidth; + leftButtonSize.width = (leftButtonSize.width >= self.minimumButtonSize.width) ? leftButtonSize.width : self.minimumButtonSize.width; - float leftButtonHeight = (leftButtonSize.height >= SLKTextInputbarMinButtonHeight) ? leftButtonSize.height : SLKTextInputbarMinButtonHeight; + float leftButtonHeight = (leftButtonSize.height >= self.minimumButtonSize.height) ? leftButtonSize.height : self.minimumButtonSize.height; self.leftButtonHC.constant = roundf(leftButtonHeight); self.leftButtonBottomMarginC.constant = roundf((self.intrinsicContentSize.height - leftButtonHeight) / 2.0) + self.slk_textViewHeight / 2.0; } @@ -809,7 +849,7 @@ - (void)slk_updateConstraintConstants self.rightButtonWC.constant = [self slk_appropriateRightButtonWidth]; self.rightMarginWC.constant = [self slk_appropriateRightButtonMargin]; - float rightButtonHeight = (rightButtonSize.height >= SLKTextInputbarMinButtonHeight) ? rightButtonSize.height : SLKTextInputbarMinButtonHeight; + float rightButtonHeight = (rightButtonSize.height >= self.minimumButtonSize.height) ? rightButtonSize.height : self.minimumButtonSize.height; self.rightButtonHC.constant = roundf(rightButtonHeight); self.rightButtonBottomMarginC.constant = roundf((self.intrinsicContentSize.height - rightButtonHeight) / 2.0) + self.slk_textViewHeight / 2.0; } @@ -841,7 +881,7 @@ - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(N [[NSNotificationCenter defaultCenter] postNotificationName:SLKTextInputbarDidMoveNotification object:self userInfo:@{@"origin": [NSValue valueWithCGPoint:self.previousOrigin]}]; } } - else if ([object isEqual:self.leftButton.imageView] && [keyPath isEqualToString:NSStringFromSelector(@selector(image))]) { + else if ([object isEqual:self.observedLeftButtonImageView] && [keyPath isEqualToString:NSStringFromSelector(@selector(image))]) { UIImage *newImage = change[NSKeyValueChangeNewKey]; UIImage *oldImage = change[NSKeyValueChangeOldKey]; @@ -850,7 +890,7 @@ - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(N [self slk_updateConstraintConstants]; } } - else if ([object isEqual:self.rightButton.titleLabel] && [keyPath isEqualToString:NSStringFromSelector(@selector(font))]) { + else if ([object isEqual:self.observedRightButtonTitleLabel] && [keyPath isEqualToString:NSStringFromSelector(@selector(font))]) { [self slk_updateConstraintConstants]; } @@ -900,8 +940,8 @@ - (void)dealloc [self slk_unregisterNotifications]; [self slk_unregisterFrom:self.layer forSelector:@selector(position)]; - [self slk_unregisterFrom:self.leftButton.imageView forSelector:@selector(image)]; - [self slk_unregisterFrom:self.rightButton.titleLabel forSelector:@selector(font)]; + [self slk_unregisterFrom:self.observedLeftButtonImageView forSelector:@selector(image)]; + [self slk_unregisterFrom:self.observedRightButtonTitleLabel forSelector:@selector(font)]; [self.typingView removeObserver:self forKeyPath:@"visible"]; } diff --git a/Source/SLKTextViewController.h b/Source/SLKTextViewController.h index bd52bc8f..baeb1e6a 100644 --- a/Source/SLKTextViewController.h +++ b/Source/SLKTextViewController.h @@ -96,6 +96,14 @@ NS_CLASS_AVAILABLE_IOS(7_0) @interface SLKTextViewController : UIViewController */ @property (nonatomic, assign, getter = isInverted) BOOL inverted; +/** + YES if the scrollView should extend behind the text input bar (and the reply view) instead of ending at its top edge. Default is NO. + When enabled, the scrollView keeps its full height (minus the keyboard) and the area covered by the text input bar is + reserved using the scrollView's bottom content inset instead. Use this for a translucent/glass text input bar, + so the content scrolls underneath it. + */ +@property (nonatomic, assign) BOOL scrollViewExtendsBehindTextInputbar; + /** YES if the view controller is presented inside of a popover controller. If YES, the keyboard won't move the text input bar and tapping on the tableView/collectionView will not cause the keyboard to be dismissed. This property is compatible only with iPad. */ @property (nonatomic, assign, getter = isPresentedInPopover) BOOL presentedInPopover; diff --git a/Source/SLKTextViewController.m b/Source/SLKTextViewController.m index e496907b..9a75020c 100644 --- a/Source/SLKTextViewController.m +++ b/Source/SLKTextViewController.m @@ -41,6 +41,11 @@ @interface SLKTextViewController () @property (nonatomic, strong) NSLayoutConstraint *autoCompletionViewHC; @property (nonatomic, strong) NSLayoutConstraint *keyboardHC; +// The scrollView's bottom edge is pinned to the top of the reply view by default. When the scrollView extends +// behind the text input bar, that constraint is replaced by the one pinning it to the bottom of the view. +@property (nonatomic, strong) NSLayoutConstraint *scrollViewBottomToReplyViewC; +@property (nonatomic, strong) NSLayoutConstraint *scrollViewBottomToViewC; + // YES if the user is moving the keyboard with a gesture @property (nonatomic, assign, getter = isMovingKeyboard) BOOL movingKeyboard; @@ -235,7 +240,7 @@ - (void)viewWillLayoutSubviews - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; - + // Make sure that the background view of textInputBar (UIToolBar) // covers the safe area bottom gap. if (@available(iOS 11.0, *)) { @@ -413,7 +418,7 @@ - (CGFloat)slk_appropriateKeyboardHeightFromNotification:(NSNotification *)notif { // Let's first detect keyboard special states such as external keyboard, undocked or split layouts. [self slk_detectKeyboardStatesInNotification:notification]; - + if ([self ignoreTextInputbarAdjustment]) { return [self slk_appropriateBottomMargin]; } @@ -477,6 +482,17 @@ - (CGFloat)slk_appropriateScrollViewHeight { CGFloat scrollViewHeight = CGRectGetHeight(self.view.bounds); + if (self.scrollViewExtendsBehindTextInputbar) { + // The scrollView always covers the whole view, even behind the keyboard. Everything covering it is + // reserved using the bottom content inset instead, see -slk_appropriateScrollViewBottomInset. + // + // Note: Resizing it together with the keyboard does not work. On dismissal the scrollView is resized + // before its safe area insets are restored, and in that moment UIKit clamps the content offset to a + // maximum that is a safe area too small. That offset is never restored, leaving the content behind the + // text input bar. + return scrollViewHeight; + } + scrollViewHeight -= self.keyboardHC.constant; scrollViewHeight -= self.textInputbarHC.constant; scrollViewHeight -= self.autoCompletionViewHC.constant; @@ -485,6 +501,54 @@ - (CGFloat)slk_appropriateScrollViewHeight else return scrollViewHeight; } +- (void)slk_updateScrollViewBottomConstraint +{ + BOOL extendsBehindTextInputbar = self.scrollViewExtendsBehindTextInputbar; + + if (self.scrollViewBottomToReplyViewC.active == !extendsBehindTextInputbar) { + return; + } + + // Never leave the scrollView without a bottom anchor, so deactivate first + if (extendsBehindTextInputbar) { + self.scrollViewBottomToReplyViewC.active = NO; + self.scrollViewBottomToViewC.active = YES; + } + else { + self.scrollViewBottomToViewC.active = NO; + self.scrollViewBottomToReplyViewC.active = YES; + } +} + +- (CGFloat)slk_appropriateScrollViewBottomInset +{ + if (!self.scrollViewExtendsBehindTextInputbar) { + return 0.0; + } + + // Reserve the space of everything covering the scrollView at its bottom edge. Since the scrollView reaches + // down to the bottom of the view, that starts with whatever is below the text input bar: the keyboard, or + // the bottom margin (e.g. the home indicator) when no keyboard is visible. + CGFloat bottomInset = self.keyboardHC.constant; + bottomInset += self.textInputbarHC.constant; + bottomInset += self.replyViewHC.constant; + bottomInset += self.autoCompletionViewHC.constant; + + // The scrollView covers the bottom safe area of the view completely and adds it to its adjusted content + // inset on its own, so it must not be reserved twice + if (@available(iOS 11.0, *)) { + if (self.scrollViewProxy.contentInsetAdjustmentBehavior != UIScrollViewContentInsetAdjustmentNever) { + bottomInset -= self.view.safeAreaInsets.bottom; + } + } + + if (bottomInset < 0) { + return 0.0; + } + + return bottomInset; +} + - (CGFloat)slk_topBarsHeight { // No need to adjust if the edge isn't available @@ -607,6 +671,21 @@ - (void)setInverted:(BOOL)inverted self.scrollViewProxy.transform = inverted ? CGAffineTransformMake(1, 0, 0, -1, 0, 0) : CGAffineTransformIdentity; } +- (void)setScrollViewExtendsBehindTextInputbar:(BOOL)scrollViewExtendsBehindTextInputbar +{ + if (_scrollViewExtendsBehindTextInputbar == scrollViewExtendsBehindTextInputbar) { + return; + } + + _scrollViewExtendsBehindTextInputbar = scrollViewExtendsBehindTextInputbar; + + if (self.isViewLoaded) { + [self slk_updateScrollViewBottomConstraint]; + [self slk_updateViewConstraints]; + [self slk_adjustContentConfigurationIfNeeded]; + } +} + - (void)setBounces:(BOOL)bounces { _bounces = bounces; @@ -724,7 +803,10 @@ - (void)textDidUpdate:(BOOL)animated CGPoint newOffset = CGPointMake(0, self.scrollViewProxy.contentOffset.y + inputBarHeightDelta); self.textInputbarHC.constant = inputbarHeight; self.scrollViewHC.constant = [self slk_appropriateScrollViewHeight]; - + + // Make sure the reserved space at the bottom of the scrollView grew/shrunk before adjusting its content offset + [self slk_adjustContentConfigurationIfNeeded]; + if (animated) { BOOL bounces = self.bounces && [self.textView isFirstResponder]; @@ -1084,7 +1166,7 @@ - (void)slk_detectKeyboardStatesInNotification:(NSNotification *)notification // We want these rects in the correct coordinate space as well. CGRect convertBegin = [baseView convertRect:beginRect fromView:nil]; CGRect convertEnd = [baseView convertRect:endRect fromView:nil]; - + if ([notification.name isEqualToString:UIKeyboardWillShowNotification]) { if (convertEnd.origin.y >= viewBounds.size.height) { _externalKeyboardDetected = YES; @@ -1103,7 +1185,7 @@ - (void)slk_detectKeyboardStatesInNotification:(NSNotification *)notification // to take the y-position additionally into account to correctly detect undocked keyboards CGRect frameOnScreen = [baseView convertRect:baseView.frame toCoordinateSpace:[UIScreen mainScreen].coordinateSpace]; CGFloat yPositionOnScreen = MAX(0.0, CGRectGetMinY(frameOnScreen)); - + if (SLK_IS_IPAD && (CGRectGetMaxY(convertEnd) + yPositionOnScreen) < CGRectGetMaxY(screenBounds)) { // The keyboard is undocked or split (iPad Only) @@ -1117,7 +1199,7 @@ - (void)slk_detectKeyboardStatesInNotification:(NSNotification *)notification - (void)slk_adjustContentConfigurationIfNeeded { UIEdgeInsets contentInset = self.scrollViewProxy.contentInset; - + // When inverted, we need to substract the top bars height (generally status bar + navigation bar's) to align the top of the // scrollView correctly to its top edge. if (self.inverted) { @@ -1125,9 +1207,9 @@ - (void)slk_adjustContentConfigurationIfNeeded contentInset.top = contentInset.bottom > 0.0 ? 0.0 : contentInset.top; } else { - contentInset.bottom = 0.0; + contentInset.bottom = [self slk_appropriateScrollViewBottomInset]; } - + self.scrollViewProxy.contentInset = contentInset; self.scrollViewProxy.scrollIndicatorInsets = contentInset; } @@ -1203,7 +1285,7 @@ - (void)didPressArrowKey:(UIKeyCommand *)keyCommand - (void)slk_willShowOrHideKeyboard:(NSNotification *)notification { SLKKeyboardStatus status = [self slk_keyboardStatusForNotification:notification]; - + // Skips if the view isn't visible. if (!self.isViewVisible) { return; @@ -1283,15 +1365,30 @@ - (void)slk_willShowOrHideKeyboard:(NSNotification *)notification { // Content Offset correction if not inverted and not auto-completing. if (!self.isInverted && !self.isAutoCompleting) { - + + // Read the content offset before anything else. Reserving less space at the bottom lowers the maximum + // content offset, which makes UIKit clamp the current one right away. Correcting that clamped offset + // by the keyboard delta again would scroll the content twice as far. + CGPoint contentOffset = scrollView.contentOffset; + + // Make sure the reserved space at the bottom of the scrollView is up to date before correcting its offset + [self slk_adjustContentConfigurationIfNeeded]; + CGFloat scrollViewHeight = self.scrollViewHC.constant; CGFloat keyboardHeight = self.keyboardHC.constant; CGSize contentSize = scrollView.contentSize; - CGPoint contentOffset = scrollView.contentOffset; - - CGFloat newOffset = MIN(contentSize.height - scrollViewHeight, + + // The adjusted content inset is up to date here: it was just recalculated above and the frame of the + // scrollView (and with it its safe area) does not change with the keyboard + CGFloat maximumOffset = contentSize.height - scrollViewHeight; + + if (@available(iOS 11.0, *)) { + maximumOffset += scrollView.adjustedContentInset.bottom; + } + + CGFloat newOffset = MIN(maximumOffset, contentOffset.y + keyboardHeight - previousKeyboardHeight); - + scrollView.contentOffset = CGPointMake(contentOffset.x, newOffset); } @@ -1490,8 +1587,15 @@ - (void)slk_willShowOrHideTypeIndicatorView:(UIView *)v CGFloat height = view.isVisible ? systemLayoutSizeHeight : 0.0; self.replyViewHC.constant = height; - self.scrollViewHC.constant -= height; - + + if (self.scrollViewExtendsBehindTextInputbar) { + // The reply view overlays the scrollView, so only the reserved space at its bottom changes + [self slk_adjustContentConfigurationIfNeeded]; + } + else { + self.scrollViewHC.constant -= height; + } + if (view.isVisible) { view.hidden = NO; } @@ -2143,7 +2247,10 @@ - (void)slk_setupViewConstraints @"textInputbar": self.textInputbar }; - [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[scrollView(0@750)][replyProxyView(0)]-0@999-[textInputbar(0)]|" options:0 metrics:nil views:views]]; + // The scrollView's bottom edge is set up explicitly (see below), so it can be exchanged when the scrollView + // should extend behind the text input bar + [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[scrollView(0@750)]" options:0 metrics:nil views:views]]; + [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:[replyProxyView(0)]-0@999-[textInputbar(0)]|" options:0 metrics:nil views:views]]; [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|-(>=0)-[autoCompletionView(0@750)][replyProxyView]" options:0 metrics:nil views:views]]; [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[scrollView]|" options:0 metrics:nil views:views]]; [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[autoCompletionView]|" options:0 metrics:nil views:views]]; @@ -2159,7 +2266,14 @@ - (void)slk_setupViewConstraints self.replyViewHC = [self.view slk_constraintForAttribute:NSLayoutAttributeHeight firstItem:self.replyProxyView secondItem:nil]; self.textInputbarHC = [self.view slk_constraintForAttribute:NSLayoutAttributeHeight firstItem:self.textInputbar secondItem:nil]; self.keyboardHC = [self.view slk_constraintForAttribute:NSLayoutAttributeBottom firstItem:self.view secondItem:self.textInputbar]; - + + // The scrollView's height constraint is optional (750), its frame is determined by its bottom edge. By default + // that is the top of the reply view, when extending behind the text input bar it is the bottom of the view. + self.scrollViewBottomToReplyViewC = [self.scrollViewProxy.bottomAnchor constraintEqualToAnchor:self.replyProxyView.topAnchor]; + self.scrollViewBottomToViewC = [self.scrollViewProxy.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor]; + self.scrollViewBottomToReplyViewC.active = YES; + + [self slk_updateScrollViewBottomConstraint]; [self slk_updateViewConstraints]; } diff --git a/Source/UIScrollView+SLKAdditions.m b/Source/UIScrollView+SLKAdditions.m index 872e47aa..4f2fabdf 100644 --- a/Source/UIScrollView+SLKAdditions.m +++ b/Source/UIScrollView+SLKAdditions.m @@ -26,12 +26,23 @@ - (void)slk_scrollToBottomAnimated:(BOOL)animated - (BOOL)slk_canScroll { - if (self.contentSize.height > CGRectGetHeight(self.frame)) { + if (self.contentSize.height + [self slk_bottomInset] > CGRectGetHeight(self.frame)) { return YES; } return NO; } +- (CGFloat)slk_bottomInset +{ + // Content covered by e.g. a translucent text input bar is reserved using the bottom content inset, + // so it needs to be taken into account when scrolling to (or detecting) the bottom + if (@available(iOS 11.0, *)) { + return self.adjustedContentInset.bottom; + } + + return self.contentInset.bottom; +} + - (BOOL)slk_isAtTop { return CGRectGetMinY([self slk_visibleRect]) <= CGRectGetMinY(self.bounds); @@ -52,7 +63,7 @@ - (CGRect)slk_visibleRect - (CGRect)slk_bottomRect { - return CGRectMake(0.0, self.contentSize.height - CGRectGetHeight(self.bounds), CGRectGetWidth(self.bounds), CGRectGetHeight(self.bounds)); + return CGRectMake(0.0, self.contentSize.height + [self slk_bottomInset] - CGRectGetHeight(self.bounds), CGRectGetWidth(self.bounds), CGRectGetHeight(self.bounds)); } - (void)slk_stopScrolling From 9fe3a4211a75c46c6afd13cf999b55b77160394d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Fri, 7 Aug 2026 11:39:04 +0200 Subject: [PATCH 2/3] fix: Typing indicator content push --- Source/SLKTextInputbar.m | 16 ++++++++++++- Source/SLKTextViewController.m | 44 +++++++++++++++++++++++++--------- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/Source/SLKTextInputbar.m b/Source/SLKTextInputbar.m index 052430dd..9b2b8968 100644 --- a/Source/SLKTextInputbar.m +++ b/Source/SLKTextInputbar.m @@ -820,6 +820,8 @@ - (void)slk_updateConstraintConstants self.rightButtonWC.constant = zero; self.rightButtonHC.constant = zero; self.rightMarginWC.constant = zero; + + [self slk_updateButtonVisibility]; } else { self.editorContentViewHC.constant = zero; @@ -829,9 +831,11 @@ - (void)slk_updateConstraintConstants self.leftButtonHC.constant = zero; self.rightButtonHC.constant = zero; + [self slk_updateButtonVisibility]; + return; } - + CGSize leftButtonSize = [self.leftButton imageForState:self.leftButton.state].size; CGSize rightButtonSize = [self.rightButton imageForState:self.rightButton.state].size; @@ -852,9 +856,19 @@ - (void)slk_updateConstraintConstants float rightButtonHeight = (rightButtonSize.height >= self.minimumButtonSize.height) ? rightButtonSize.height : self.minimumButtonSize.height; self.rightButtonHC.constant = roundf(rightButtonHeight); self.rightButtonBottomMarginC.constant = roundf((self.intrinsicContentSize.height - rightButtonHeight) / 2.0) + self.slk_textViewHeight / 2.0; + + [self slk_updateButtonVisibility]; } } +- (void)slk_updateButtonVisibility +{ + // Sizing a button to zero does not necessarily hide it: a UIButtonConfiguration draws its background + // (a glass capsule for example) outside of the button's bounds. + self.leftButton.hidden = (self.leftButtonWC.constant <= 0.0 || self.leftButtonHC.constant <= 0.0); + self.rightButton.hidden = (self.rightButtonWC.constant <= 0.0 || self.rightButtonHC.constant <= 0.0); +} + #pragma mark - Observers diff --git a/Source/SLKTextViewController.m b/Source/SLKTextViewController.m index 9a75020c..4b86d28a 100644 --- a/Source/SLKTextViewController.m +++ b/Source/SLKTextViewController.m @@ -46,6 +46,9 @@ @interface SLKTextViewController () @property (nonatomic, strong) NSLayoutConstraint *scrollViewBottomToReplyViewC; @property (nonatomic, strong) NSLayoutConstraint *scrollViewBottomToViewC; +// YES while the height of the typing indicator is being applied to the inputbar +@property (nonatomic, assign, getter = isUpdatingTypingIndicatorHeight) BOOL updatingTypingIndicatorHeight; + // YES if the user is moving the keyboard with a gesture @property (nonatomic, assign, getter = isMovingKeyboard) BOOL movingKeyboard; @@ -790,34 +793,46 @@ - (void)textDidUpdate:(BOOL)animated if (self.isTextInputbarHidden) { return; } - + [_textInputbar layoutIfNeeded]; CGFloat inputbarHeight = _textInputbar.appropriateHeight; - + _textInputbar.rightButton.enabled = [self canPressRightButton]; _textInputbar.editorRightButton.enabled = [self canPressRightButton]; - + if (inputbarHeight != self.textInputbarHC.constant) { CGFloat inputBarHeightDelta = inputbarHeight - self.textInputbarHC.constant; CGPoint newOffset = CGPointMake(0, self.scrollViewProxy.contentOffset.y + inputBarHeightDelta); + + // A typing indicator the content scrolls behind covers it instead of pushing it away, so the content + // keeps its position - and nothing scrolls back when the indicator disappears again. The composer + // still pushes it, so the message being written doesn't cover the last messages. + BOOL adjustsContentOffset = !self.isInverted; + + if (self.scrollViewExtendsBehindTextInputbar && self.isUpdatingTypingIndicatorHeight) { + adjustsContentOffset = NO; + } + self.textInputbarHC.constant = inputbarHeight; self.scrollViewHC.constant = [self slk_appropriateScrollViewHeight]; - // Make sure the reserved space at the bottom of the scrollView grew/shrunk before adjusting its content offset - [self slk_adjustContentConfigurationIfNeeded]; - if (animated) { - + BOOL bounces = self.bounces && [self.textView isFirstResponder]; - + __weak typeof(self) weakSelf = self; - + [self.view slk_animateLayoutIfNeededWithBounce:bounces options:UIViewAnimationOptionCurveEaseInOut|UIViewAnimationOptionLayoutSubviews|UIViewAnimationOptionBeginFromCurrentState animations:^{ - if (!self.isInverted) { - self.scrollViewProxy.contentOffset = newOffset; + // Reserving less space at the bottom lowers the maximum content + // offset, so UIKit clamps the current one right away. Inside the + // animation that clamp moves with the input bar instead of jumping. + [weakSelf slk_adjustContentConfigurationIfNeeded]; + + if (adjustsContentOffset) { + weakSelf.scrollViewProxy.contentOffset = newOffset; } if (weakSelf.textInputbar.isEditing) { [weakSelf.textView slk_scrollToCaretPositonAnimated:NO]; @@ -825,6 +840,7 @@ - (void)textDidUpdate:(BOOL)animated }]; } else { + [self slk_adjustContentConfigurationIfNeeded]; [self.view layoutIfNeeded]; } } @@ -1536,8 +1552,14 @@ - (void)slk_didChangeInputbarContentSize:(NSNotification *)notification return; } + // The inputbar posts this notification when the typing indicator changed its height. Flagged for the whole + // update, since -textDidUpdate: is re-entered while it lays out the inputbar. + self.updatingTypingIndicatorHeight = YES; + // Animated only if the view already appeared. [self textDidUpdate:self.isViewVisible]; + + self.updatingTypingIndicatorHeight = NO; } - (void)slk_didChangeTextViewSelectedRange:(NSNotification *)notification From ba4388f5542dd2293b588f612751b696c52fa19a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Sun, 9 Aug 2026 17:24:30 +0200 Subject: [PATCH 3/3] feat: Allow to reuse the left/right button in edit mode Assisted-by: ClaudeCode:claude-opus-5 --- Source/SLKTextInputbar.h | 4 ++++ Source/SLKTextInputbar.m | 22 ++++++---------------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/Source/SLKTextInputbar.h b/Source/SLKTextInputbar.h index f8255212..7720cf09 100644 --- a/Source/SLKTextInputbar.h +++ b/Source/SLKTextInputbar.h @@ -65,6 +65,10 @@ UIKIT_EXTERN NSString * const SLKTextInputbarContentSizeDidChangeNotification; /** The minimum size of the left and right button, so they keep an accessible touch target even with a small image. Default is {44, 44}. */ @property (nonatomic, assign) CGSize minimumButtonSize; +/** YES if the left and right button stay in place while editing, instead of being replaced by the buttons of the + editor content view. Use this together with an editorContentViewHeight of 0 to edit in the input bar itself. Default is NO. */ +@property (nonatomic, assign) BOOL keepsButtonsWhileEditing; + /** The minimum height based on the intrinsic content size's. */ @property (nonatomic, readonly) CGFloat minimumInputbarHeight; diff --git a/Source/SLKTextInputbar.m b/Source/SLKTextInputbar.m index 9b2b8968..7f40c874 100644 --- a/Source/SLKTextInputbar.m +++ b/Source/SLKTextInputbar.m @@ -805,13 +805,15 @@ - (void)slk_setupViewConstraints - (void)slk_updateConstraintConstants { CGFloat zero = 0.0; - + self.textViewBottomMarginC.constant = self.slk_bottomMargin; + self.editorContentViewHC.constant = self.isEditing ? self.editorContentViewHeight : zero; + + // While editing, the buttons are replaced by the ones of the editor content view + BOOL hidesButtons = (self.isEditing && !self.keepsButtonsWhileEditing) || self->_hidden; - if (self.isEditing) + if (hidesButtons) { - self.editorContentViewHC.constant = self.editorContentViewHeight; - self.leftButtonWC.constant = zero; self.leftButtonHC.constant = zero; self.leftMarginWC.constant = zero; @@ -824,18 +826,6 @@ - (void)slk_updateConstraintConstants [self slk_updateButtonVisibility]; } else { - self.editorContentViewHC.constant = zero; - - // When the inputbar is hidden, we need to hide the buttons as well - if (self->_hidden) { - self.leftButtonHC.constant = zero; - self.rightButtonHC.constant = zero; - - [self slk_updateButtonVisibility]; - - return; - } - CGSize leftButtonSize = [self.leftButton imageForState:self.leftButton.state].size; CGSize rightButtonSize = [self.rightButton imageForState:self.rightButton.state].size;