diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.java b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.java index 23ff439c..90ef863f 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.java +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.java @@ -8,6 +8,7 @@ import android.view.Gravity; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; +import android.widget.ImageView; import android.widget.TextView; import androidx.appcompat.app.ActionBar; @@ -15,6 +16,7 @@ import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.Fragment; +import com.facebook.react.bridge.JSApplicationIllegalArgumentException; import com.facebook.react.views.text.ReactFontManager; import java.util.ArrayList; @@ -184,6 +186,17 @@ public class ScreenStackHeaderConfig extends ViewGroup { ScreenStackHeaderSubview view = mConfigSubviews.get(i); ScreenStackHeaderSubview.Type type = view.getType(); + if (type == ScreenStackHeaderSubview.Type.BACK) { + // we special case BACK button header config type as we don't add it as a view into toolbar + // but instead just copy the drawable from imageview that's added as a first child to it. + View firstChild = view.getChildAt(0); + if (!(firstChild instanceof ImageView)) { + throw new JSApplicationIllegalArgumentException("Back button header config view should have Image as first child"); + } + actionBar.setHomeAsUpIndicator(((ImageView) firstChild).getDrawable()); + continue; + } + Toolbar.LayoutParams params = new Toolbar.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT); diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubview.java b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubview.java index ab1f947a..0a0c9e09 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubview.java +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubview.java @@ -18,7 +18,8 @@ public class ScreenStackHeaderSubview extends ReactViewGroup { LEFT, CENTER, TITLE, - RIGHT + RIGHT, + BACK } private int mReactWidth, mReactHeight; diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.java b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.java index 96d38bd0..f19897ba 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.java +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.java @@ -47,6 +47,8 @@ public class ScreenStackHeaderSubviewManager extends ReactViewManager { view.setType(ScreenStackHeaderSubview.Type.TITLE); } else if ("right".equals(type)) { view.setType(ScreenStackHeaderSubview.Type.RIGHT); + } else if ("back".equals(type)) { + view.setType(ScreenStackHeaderSubview.Type.BACK); } } } diff --git a/createNativeStackNavigator.js b/createNativeStackNavigator.js index 856692a5..32e4eac6 100644 --- a/createNativeStackNavigator.js +++ b/createNativeStackNavigator.js @@ -12,6 +12,7 @@ import { ScreenStack, Screen, ScreenStackHeaderConfig, + ScreenStackHeaderBackButtonImage, ScreenStackHeaderLeftView, ScreenStackHeaderRightView, ScreenStackHeaderTitleView, @@ -93,6 +94,12 @@ class StackView extends React.Component { const children = []; + if (options.backButtonImage) { + children.push( + + ); + } + if (options.headerLeft !== undefined) { children.push( diff --git a/ios/RNSScreenStackHeaderConfig.h b/ios/RNSScreenStackHeaderConfig.h index 6c9b90af..b99d963b 100644 --- a/ios/RNSScreenStackHeaderConfig.h +++ b/ios/RNSScreenStackHeaderConfig.h @@ -34,6 +34,7 @@ @end typedef NS_ENUM(NSInteger, RNSScreenStackHeaderSubviewType) { + RNSScreenStackHeaderSubviewTypeBackButton, RNSScreenStackHeaderSubviewTypeLeft, RNSScreenStackHeaderSubviewTypeRight, RNSScreenStackHeaderSubviewTypeTitle, diff --git a/ios/RNSScreenStackHeaderConfig.m b/ios/RNSScreenStackHeaderConfig.m index 3992b611..d34d5a4d 100644 --- a/ios/RNSScreenStackHeaderConfig.m +++ b/ios/RNSScreenStackHeaderConfig.m @@ -5,6 +5,20 @@ #import #import #import +#import +#import +#import + +// Some RN private method hacking below. Couldn't figure out better way to access image data +// of a given RCTImageView. See more comments in the code section processing SubviewTypeBackButton +@interface RCTImageView (Private) +- (UIImage*)image; +@end + +@interface RCTImageLoader (Private) +- (id)imageCache; +@end + @interface RNSScreenHeaderItemMeasurements : NSObject @property (nonatomic, readonly) CGSize headerSize; @@ -30,6 +44,7 @@ @interface RNSScreenStackHeaderSubview : UIView +@property (nonatomic, weak) RCTBridge *bridge; @property (nonatomic, weak) UIView *reactSuperview; @property (nonatomic) RNSScreenStackHeaderSubviewType type; @@ -150,6 +165,15 @@ [navbar setLargeTitleTextAttributes:largeAttrs]; } } + + UIImage *backButtonImage = [self loadBackButtonImageInViewController:vc withConfig:config]; + if (backButtonImage) { + navbar.backIndicatorImage = backButtonImage; + navbar.backIndicatorTransitionMaskImage = backButtonImage; + } else if (navbar.backIndicatorImage) { + navbar.backIndicatorImage = nil; + navbar.backIndicatorTransitionMaskImage = nil; + } } } @@ -164,6 +188,59 @@ } } ++ (UIImage*)loadBackButtonImageInViewController:(UIViewController *)vc + withConfig:(RNSScreenStackHeaderConfig *)config +{ + BOOL hasBackButtonImage = NO; + for (RNSScreenStackHeaderSubview *subview in config.reactSubviews) { + if (subview.type == RNSScreenStackHeaderSubviewTypeBackButton && subview.subviews.count > 0) { + hasBackButtonImage = YES; + RCTImageView *imageView = subview.subviews[0]; + UIImage *image = imageView.image; + // IMPORTANT!!! + // image can be nil in DEV MODE ONLY + // + // It is so, because in dev mode images are loaded over HTTP from the packager. In that case + // we first check if image is already loaded in cache and if it is, we take it from cache and + // display immediately. Otherwise we wait for the transition to finish and retry updating + // header config. + // Unfortunately due to some problems in UIKit we cannot update the image while the screen + // transition is ongoing. This results in the settings being reset after the transition is done + // to the state from before the transition. + if (image == nil) { + // in DEV MODE we try to load from cache (we use private API for that as it is not exposed + // publically in headers). + RCTImageSource *source = imageView.imageSources[0]; + image = [subview.bridge.imageLoader.imageCache + imageForUrl:source.request.URL.absoluteString + size:source.size + scale:source.scale + resizeMode:imageView.resizeMode]; + } + if (image == nil) { + // This will be triggered if the image is not in the cache yet. What we do is we wait until + // the end of transition and run header config updates again. We could potentially wait for + // image on load to trigger, but that would require even more private method hacking. + if (vc.transitionCoordinator) { + [vc.transitionCoordinator animateAlongsideTransition:^(id _Nonnull context) { + // nothing, we just want completion + } completion:^(id _Nonnull context) { + // in order for new back button image to be loaded we need to trigger another change + // in back button props that'd make UIKit redraw the button. Otherwise the changes are + // not reflected. Here we change back button visibility which is then immediately restored + vc.navigationItem.hidesBackButton = YES; + [config updateViewControllerIfNeeded]; + }]; + } + return [UIImage new]; + } else { + return image; + } + } + } + return nil; +} + + (void)willShowViewController:(UIViewController *)vc withConfig:(RNSScreenStackHeaderConfig *)config { [self updateViewController:vc withConfig:config]; @@ -201,7 +278,6 @@ } navitem.title = config.title; - navitem.hidesBackButton = config.hideBackButton; if (config.backTitle != nil) { prevItem.backBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:config.backTitle @@ -290,11 +366,19 @@ appearance.largeTitleTextAttributes = largeAttrs; } + UIImage *backButtonImage = [self loadBackButtonImageInViewController:vc withConfig:config]; + if (backButtonImage) { + [appearance setBackIndicatorImage:backButtonImage transitionMaskImage:backButtonImage]; + } else if (appearance.backIndicatorImage) { + [appearance setBackIndicatorImage:nil transitionMaskImage:nil]; + } + navitem.standardAppearance = appearance; navitem.compactAppearance = appearance; navitem.scrollEdgeAppearance = appearance; } #endif + navitem.hidesBackButton = config.hideBackButton; navitem.leftBarButtonItem = nil; navitem.rightBarButtonItem = nil; navitem.titleView = nil; @@ -378,6 +462,7 @@ RCT_EXPORT_VIEW_PROPERTY(gestureEnabled, BOOL) @implementation RCTConvert (RNSScreenStackHeader) RCT_ENUM_CONVERTER(RNSScreenStackHeaderSubviewType, (@{ + @"back": @(RNSScreenStackHeaderSubviewTypeBackButton), @"left": @(RNSScreenStackHeaderSubviewTypeLeft), @"right": @(RNSScreenStackHeaderSubviewTypeRight), @"title": @(RNSScreenStackHeaderSubviewTypeTitle), @@ -386,9 +471,7 @@ RCT_ENUM_CONVERTER(RNSScreenStackHeaderSubviewType, (@{ @end -@implementation RNSScreenStackHeaderSubview { - __weak RCTBridge *_bridge; -} +@implementation RNSScreenStackHeaderSubview - (instancetype)initWithBridge:(RCTBridge *)bridge { diff --git a/src/screens.native.js b/src/screens.native.js index 26b52686..63335d57 100644 --- a/src/screens.native.js +++ b/src/screens.native.js @@ -4,6 +4,7 @@ import { requireNativeComponent, View, UIManager, + Image, StyleSheet, } from 'react-native'; import { version } from 'react-native/Libraries/Core/ReactNativeVersion'; @@ -146,6 +147,14 @@ const styles = StyleSheet.create({ }, }); +const ScreenStackHeaderBackButtonImage = props => ( + + + +); + const ScreenStackHeaderRightView = props => (