Skip to content

feat(ios): Ti.UI.scrollView contentInsets / scrollIndicatorInsets#14475

Draft
mbender74 wants to merge 50 commits into
tidev:mainfrom
mbender74:feature/scrollview-contentinset-ios
Draft

feat(ios): Ti.UI.scrollView contentInsets / scrollIndicatorInsets#14475
mbender74 wants to merge 50 commits into
tidev:mainfrom
mbender74:feature/scrollview-contentinset-ios

Conversation

@mbender74

@mbender74 mbender74 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Add contentInsets, verticalScrollIndicatorInsets, horizontalScrollIndicatorInsets, and scrollIndicatorColor properties to Ti.UI.ScrollView (iOS)

Description:

This PR adds native contentInsets, verticalScrollIndicatorInsets, horizontalScrollIndicatorInsets, and scrollIndicatorColor properties to Ti.UI.ScrollView for iOS, eliminating the
need for external modules like TableViewExtension for basic inset management. These properties provide direct control over content positioning, scroll indicator placement, and
indicator coloring within the scroll view, with optional animation support.

Motivation

Previously, developers needed external modules (e.g., TableViewExtension) to set content and scroll indicator insets on scroll views. This PR brings this functionality natively
into the Titanium SDK, providing:

  1. Native control over content insets without module dependencies
  2. Animation support with configurable duration
  3. Safe area integration for iOS tab bar offsets
  4. Independent vertical and horizontal scroll indicator insets (iOS 11+)
  5. Custom scroll indicator coloring via scrollIndicatorColor

Implementation Details

Modified Files

iphone/Classes/TiUIScrollViewProxy.m

  • Added setContentInsets:, setVerticalScrollIndicatorInsets:, setHorizontalScrollIndicatorInsets:, and setScrollIndicatorColor: getter/setter methods
  • Properties stored via KVC (replaceValue:forKey:notification:) to survive initialization before view attachment
  • Re-apply insets and color in windowWillOpen after first layout (safe area insets are calculated at that point)
  • Disable automatic iOS inset adjustment before setting manual values:
    • contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever
    • automaticallyAdjustsScrollIndicatorInsets = NO
  • Support inline animation options: animated, duration (ms, default 300), safearea (contentInsets only, default 0)
  • Use native iOS 11+ properties verticalScrollIndicatorInsets and horizontalScrollIndicatorInsets for independent control
  • Force full opacity for scrollIndicatorColor (alpha values are ignored)
  • Optimize scroll performance by removing expensive operations from scrollViewDidScroll:

apidoc/Titanium/UI/ScrollView.yml

  • Documented all four properties with iOS-only platforms (iphone, ipad, macos)
  • Since version "13.4.0"
  • Added usage examples with animation options
  • Clarified that verticalScrollIndicatorInsets (left/right) and horizontalScrollIndicatorInsets (top/bottom) are independent
  • Documented iOS 16+ limitation for scrollIndicatorColor (only slight tinting due to private _UIScrollViewScrollIndicator class)

Key Design Decisions

  1. Flat dictionary format — All options (inset values + animation) are in the same dictionary, not nested:
    scrollView.contentInsets = {
        top: 20, left: 10, bottom: 20, right: 10,
        animated: true,
        duration: 300
    };
  2. Duration in milliseconds — Consistent with TiUIListView/TiUITableView animation behavior (default 300ms)
  3. Safe area offset — Optional safearea parameter for contentInsets to add bottom offset for iOS tab bars (default 0)
  4. Separate vertical/horizontal insets — iOS 11+ provides verticalScrollIndicatorInsets (left/right) and horizontalScrollIndicatorInsets (top/bottom) for independent control
  5. Nil-safe getters — Return {top: 0, left: 0, bottom: 0, right: 0} when view not attached or insets unset (using modern @{} literal syntax)
  6. Full opacity for scrollIndicatorColor — Alpha values in the color are ignored to ensure visibility

Usage Examples

// Basic content insets (no animation)
 scrollView.contentInsets = {top: 20, left: 10, bottom: 20, right: 10};

 // With animation (300ms default)
 scrollView.contentInsets = {
     top: 20, left: 10, bottom: 20, right: 10,
     animated: true
 };

 // Custom duration (500ms) + safe area offset for tab bar (adds to bottom only)
 scrollView.contentInsets = {
     top: 20, left: 10, bottom: 20, right: 10,
     animated: true,
     duration: 500,
     safearea: 34
 };

 // Vertical scroll indicator insets (affects left/right positioning)
 scrollView.verticalScrollIndicatorInsets = {
     top: 20, left: 10, bottom: 20, right: 10,
     animated: true,
     duration: 300
 };

 // Horizontal scroll indicator insets (affects top/bottom positioning)
 scrollView.horizontalScrollIndicatorInsets = {
     top: 20, left: 10, bottom: 20, right: 10,
     animated: true,
     duration: 300
 };

 // Custom scroll indicator color (full opacity, alpha ignored)
 scrollView.scrollIndicatorColor = '#FF0000';  // Red
 scrollView.scrollIndicatorColor = 'blue';     // Named color
 scrollView.scrollIndicatorColor = Ti.UI.createColor('#00FF00');  // Ti.UI.Color

 // Properties can be set during initialization (before view attachment)
 var scrollView = Ti.UI.createScrollView({
     contentInsets: {top: 50, left: 20, bottom: 50, right: 20},
     verticalScrollIndicatorInsets: {top: 90, bottom: 90, left: 40, right: 40},
     horizontalScrollIndicatorInsets: {top: 90, bottom: 90, left: 40, right: 40},
     scrollIndicatorColor: '#0000FF'
 });

Technical Notes

Initialization Timing

Properties set during _initWithProperties: (before the view is attached to a window) are stored via KVC and re-applied in windowWillOpen after the first layout. This ensures safe
area insets are calculated correctly.

Automatic Inset Adjustment

iOS automatically adjusts content and scroll indicator insets based on safe area layout. To allow manual control:

scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
scrollView.automaticallyAdjustsScrollIndicatorInsets = NO;

These are set BEFORE applying manual values to prevent iOS from overriding them.

Animation Implementation

if (animated && duration > 0) {
[UIView animateWithDuration:duration / 1000.0 animations:updateInsets];
} else {
updateInsets();
}

Duration is specified in milliseconds by the API but converted to seconds for UIView animateWithDuration:.

Getter Behavior

When the view is not attached or insets haven't been set, getters return a dictionary with zero values:

  • (id)contentInsets {
    if ([self viewAttached]) {
    UIEdgeInsets insets = [(TiUIScrollView *)[self view] scrollView].contentInset;
    return @{
    @"top": @(insets.top),
    @"left": @(insets.left),
    @"bottom": @(insets.bottom),
    @"right": @(insets.right)
    };
    }
    return [NSNull null];
    }

Scroll Indicator Color Implementation

On iOS <16, the scroll indicator is a UIImageView and can be tinted via tintColor and imageWithRenderingMode(AlwaysTemplate).

On iOS 16+, the scroll indicator is the private _UIScrollViewScrollIndicator class. The color is applied via backgroundColor and layer.backgroundColor. Due to the private class's
internal rendering, the color may only slightly tint the indicator.

The scrollIndicatorColor property forces full opacity (alpha values are ignored) to ensure visibility.

Performance Optimizations

  • Removed expensive applyScrollIndicatorColorToScrollView: from scrollViewDidScroll: (was called every frame)
  • Replaced layoutIfNeeded with setNeedsLayout to avoid blocking the main thread
  • Optimized class name comparison in the indicator color loop

Testing

Verified with iOS simulator (iPhone 17 Pro Max, iOS 26.2):

  • ✅ Basic insets applied correctly without animation
  • ✅ Animation transitions work smoothly with animated: true
  • ✅ Custom duration respected (tested 300ms, 500ms, 1000ms)
  • ✅ Safe area offset applies to content bottom inset
  • ✅ Vertical and horizontal scroll indicator insets independent
  • ✅ Properties set during initialization survive view attachment
  • ✅ No crashes when properties not set (defaults to zero insets)
  • ✅ KVC storage doesn't cause infinite recursion
  • ✅ scrollIndicatorColor applies color to indicators (iOS <16: full color, iOS 16+: slight tint)
  • ✅ Smooth scroll performance with optimized code

Without:
before

With contentInsets:
contentInsets

With scrollIndicatorInsets:
scrollIndicatorInsets

Android PR is already finished, will follow up after PR is accepted

mbender74 added 15 commits June 28, 2026 15:14
…View

Add iOS-only properties contentInset and scrollIndicatorInsets to
Ti.UI.ScrollView proxy. Values are dictionaries with top/left/bottom/right
keys, parsed via existing TiUtils contentInsets:. No new dependencies,
no categories — direct property getter/setter on the SDK proxy.
The property name uses the plural form 'contentInsets' to match
the native UIScrollView API naming convention.
Enhance Ti.UI.ScrollView.contentInsets with optional parameters:
- animated: Boolean to enable smooth transitions (default: false)
- duration: Animation duration in seconds (default: 0.2s)
- safearea: Bottom offset for iOS tab bars (default: 34px)

Auto-scrolls to bottom when content exceeds bounds and safe area is configured.
Replace setValue:forKey: with replaceValue:forKey:notification:NO to avoid
KVC-triggered setter recursion. The previous implementation caused 47k+
recursive calls and stack overflow crash.
…rollIndicatorInsets

iOS automatically adjusts both contentInset and scrollIndicatorInsets based
on safe area layout, overriding any manual values. This fix sets:
- contentInsetAdjustmentBehavior = Never
- automaticallyAdjustsScrollIndicatorInsets = NO

BEFORE applying the user-provided inset values.
…illOpen

Properties set during initialization are lost because the view is not yet
attached. This fix re-applies them after the first layout when safe area
insets are calculated.
…orInsets

Add detailed documentation for the new iOS-only properties including:
- Property description and usage
- Option parameters (animated, duration, safearea)
- iOS-specific behavior notes (automatic adjustment disabled)
- Code examples for animated transitions
Remove incorrect statement that scrollIndicatorInsets must be smaller than
contentInsets. Both properties are independent and can be set separately.
Remove nested 'insets' object — animation options (animated, duration, safearea)
are now directly in the dictionary alongside top/left/bottom/right.

Before:
  scrollView.contentInsets = { insets: {...}, animated: true, duration: 0.3 };

After:
  scrollView.contentInsets = { top: 20, left: 10, bottom: 20, right: 10, animated: true, duration: 0.3 };

This is more consistent with Titanium API conventions and easier to use.
@mbender74 mbender74 changed the title Feature/scrollview contentinset ios feat(iOS): Ti.UI.scrollView contentInsets / scrollIndicatorInsets Jun 29, 2026
@mbender74

mbender74 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author
    const win = Ti.UI.createWindow({
        backgroundColor: '#f5f5f5',
        title: 'contentInset Test'
    });

    // ScrollView with contentInset
    const scrollView = Ti.UI.createScrollView({
        showVerticalScrollIndicator: true,
        showHorizontalScrollIndicator: true,
        backgroundColor: 'white',
        scrollType: 'vertical',
        contentHeight:'auto',
        contentWidth:'auto',
        height: Ti.UI.FILL,
        width: Ti.UI.FILL,
        scrollIndicatorColor: '#FFFF00',
        verticalScrollIndicatorInsets: {
            top: 80,
            bottom: 80,
            left: 0,
            right: 10,
            animated:true,
            duration: 500
        },
        contentInsets: {
            top: 80,
            bottom: 80,
            left: 0,
            right: 0
        }
    });

    // Content view inside ScrollView
    const contentView = Ti.UI.createView({
        backgroundColor: 'green',
        layout:'vertical',
        width: Ti.UI.SIZE,
        height: Ti.UI.SIZE
    });

    // Add some content items to demonstrate scrolling
    for (let i = 0; i < 20; i++) {
        const item = Ti.UI.createView({
            backgroundColor: i % 2 === 0 ? '#e3f2fd' : '#fff3e0',
            width: '95%',
            height: 60,
            top: 10,
            left: '2.5%',
            borderRadius: 8,
            layout: 'horizontal',
            touchEnabled: false
        });

        const itemLabel = Ti.UI.createLabel({
            text: `Content Item ${i + 1}`,
            font: { fontSize: 16, fontWeight: 'bold' },
            color: '#333',
            left: 10,
            top: 15
        });

        item.add(itemLabel);
        contentView.add(item);
    }

    scrollView.add(contentView);
    win.add(scrollView);

@mbender74 mbender74 changed the title feat(iOS): Ti.UI.scrollView contentInsets / scrollIndicatorInsets feat(ios): Ti.UI.scrollView contentInsets / scrollIndicatorInsets Jun 29, 2026
mbender74 and others added 10 commits July 2, 2026 10:16
- Apply safeAreaOffset to insets.bottom in setContentInsets
- Convert duration from ms to seconds in setScrollIndicatorInsets
- Guard setContentInsets and setScrollIndicatorInsets against nil/NSNull
- Remove obsolete setContentInsets:value:options: overload
- Remove redundant else {} blocks
- Add tests for contentInsets and scrollIndicatorInsets properties
The code uses default 0 for safearea offset, not 34. Updated docs to match.
…ontal

- verticalScrollIndicatorInsets: left/right position vertical scrollbar
- horizontalScrollIndicatorInsets: top/bottom position horizontal scrollbar
- Both accept full top/left/bottom/right dictionary
- Update apidoc and tests accordingly
Uses tintColor on UIImageView subviews to color both vertical and
horizontal scroll indicators. Works on iOS 9+.
…ollIndicatorColor

- verticalScrollIndicatorInsets: apply top/bottom/left/right in windowWillOpen
- horizontalScrollIndicatorInsets: apply top/bottom/left/right in windowWillOpen
- scrollIndicatorColor: recursively search for UIImageView subviews
… only

Remove recursive search, only look at direct subviews of UIScrollView
Add NSLog statements to debug why scrollIndicatorColor is not working
The color setter is called before view is attached, so re-apply
it after windowWillOpen when the view hierarchy is ready.
mbender74 added 20 commits July 2, 2026 11:44
mbender74 added 3 commits July 2, 2026 16:40
- setContentInsets: drop NSDictionary literal that crashed when a partial
  inset dict (any of top/left/bottom/right missing) was passed; route
  through [TiUtils contentInsets:] like the other setters.
- windowWillOpen: re-apply contentInsets via TiUtils and re-add the saved
  safearea offset to bottom (previously lost on re-attach); same for
  vertical/horizontalScrollIndicatorInsets (previously used raw
  floatValue, inconsistent with the setter).
- scrollIndicatorColor getter: return the saved value instead of
  iterating subviews for UIImageView, which missed the private
  _UIScrollViewScrollIndicator class on iOS 16+ and returned NSNull.
- scrollIndicatorColor setter: resolve via OpaqueColorFromColor which
  handles non-RGB colorspaces (grayscale via getWhite:alpha:, pattern
  returned as-is) instead of leaving red/green/blue uninitialized.
- scrollIndicatorColor: replace the racy toggle + dispatch_async
  indicator-recreation with a layoutSubviews hook on TiUIScrollViewImpl
  that deterministically applies the configured color whenever UIKit
  lays out (and lazily (re)creates the indicators). Removes
  applyScrollIndicatorColorToScrollView: from the proxy.
- Deduplicate the three inset setters via a shared
  applyInsetsValue:usingAssignment:safeAreaToBottom: helper.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant