DeX: DensityScaleWrapperでtransform scaleアプローチに切替、babel plugin wrapStyleForDensityを除去

This commit is contained in:
harukin-expo-dev-env
2026-03-29 17:25:54 +00:00
parent b9f8ed1ea8
commit 25e13b9f41
6 changed files with 160 additions and 87 deletions

56
App.tsx
View File

@@ -1,8 +1,7 @@
import React, { useEffect } from "react";
import { Linking, Platform, UIManager } from "react-native";
import { Linking, Platform, UIManager, View, useWindowDimensions, PixelRatio } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "./utils/disableFontScaling"; // グローバルなフォントスケーリング無効化
import "./utils/scaleTextForDensity"; // 低密度ディスプレイ(DeX等)でのテキスト自動拡大
import { AppContainer } from "./Apps";
import { UpdateAsync } from "./UpdateAsync";
import { LogBox } from "react-native";
@@ -138,13 +137,56 @@ export default function App() {
<DeviceOrientationChangeProvider>
<SafeAreaProvider>
<StatusbarDetect />
<GestureHandlerRootView style={{ flex: 1 }}>
<ProviderTree>
<AppContainer />
</ProviderTree>
</GestureHandlerRootView>
<DensityScaleWrapper>
<GestureHandlerRootView style={{ flex: 1 }}>
<ProviderTree>
<AppContainer />
</ProviderTree>
</GestureHandlerRootView>
</DensityScaleWrapper>
</SafeAreaProvider>
</DeviceOrientationChangeProvider>
</AppThemeProvider>
);
}
/**
* 低密度ディスプレイDeX等で全体を transform scale で拡大。
* テキストもアイコンもコンテナも一括で拡大される。
* 内部レイアウトは width/height を 1/scale にしてはみ出し防止。
*/
function DensityScaleWrapper({ children }: { children: React.ReactNode }) {
const { width, height } = useWindowDimensions();
const pr = PixelRatio.get();
if (pr >= 2.0) {
// 通常密度 → スケーリング不要
return <>{children}</>;
}
// DeX(PR=0.756): 2.625/0.756=3.47 → 大きすぎるので 1.8 に制限
const scale = Math.min(1.8, 2.0 / pr);
const innerWidth = width / scale;
const innerHeight = height / scale;
return (
<View
style={{
width,
height,
overflow: "hidden",
}}
>
<View
style={{
width: innerWidth,
height: innerHeight,
transform: [{ scale }],
transformOrigin: "top left",
}}
>
{children}
</View>
</View>
);
}

View File

@@ -106,6 +106,7 @@
},
"plugins": [
"./plugins/with-android-local-properties",
"./plugins/with-nfc-widget-guard",
"@bacons/apple-targets",
[
"expo-font",

BIN
assets/icons/icon_2048.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

View File

@@ -3,12 +3,15 @@ import { FlexWidget, TextWidget } from "react-native-android-widget";
import { getDelayData } from "./TraInfoEXWidget";
import { getInfoString } from "./InfoWidget";
import { getFelicaQuickAccessData } from "./FelicaQuickAccessWidget";
import { isAvailable as isNfcAvailable } from "../../modules/expo-felica-reader/src";
export async function getShortcutData() {
const hasNfc = isNfcAvailable();
const [delayResult, infoResult, felicaResult] = await Promise.allSettled([
getDelayData(),
getInfoString(),
getFelicaQuickAccessData(),
hasNfc ? getFelicaQuickAccessData() : Promise.resolve({ amountText: "" }),
]);
const delayCount =
@@ -26,13 +29,14 @@ export async function getShortcutData() {
? felicaResult.value.amountText
: "未読取";
return { delayCount, hasInfo, amountText };
return { delayCount, hasInfo, amountText, hasNfc };
}
export type ShortcutWidgetProps = {
delayCount: number;
hasInfo: boolean;
amountText: string;
hasNfc: boolean;
};
const TILE_BG = "#E8F4FB";
@@ -130,7 +134,7 @@ function GridTile({
);
}
export function ShortcutWidget({ delayCount, hasInfo, amountText }: ShortcutWidgetProps) {
export function ShortcutWidget({ delayCount, hasInfo, amountText, hasNfc }: ShortcutWidgetProps) {
return (
<FlexWidget
style={{
@@ -184,16 +188,20 @@ export function ShortcutWidget({ delayCount, hasInfo, amountText }: ShortcutWidg
badgeText={hasInfo ? "あり" : "なし"}
badgeColor={hasInfo ? "#FF7043" : "#9E9E9E"}
/>
<FlexWidget style={{ width: SPACING, height: "match_parent" }}>
<TextWidget text="" style={{ fontSize: 1 }} />
</FlexWidget>
<GridTile
icon="💳"
label="ICカード"
sub={amountText}
subColor="#0099CC"
uri="jrshikoku://open/felica"
/>
{hasNfc && (
<>
<FlexWidget style={{ width: SPACING, height: "match_parent" }}>
<TextWidget text="" style={{ fontSize: 1 }} />
</FlexWidget>
<GridTile
icon="💳"
label="ICカード"
sub={amountText}
subColor="#0099CC"
uri="jrshikoku://open/felica"
/>
</>
)}
</FlexWidget>
{/* Spacer between rows */}

View File

@@ -1,8 +1,8 @@
/**
* Babel plugin for Text/TextInput:
* 1. allowFontScaling={false} を全要素に追加
* 2. style プロップを global.__scaleTextStyle() でラップ
* (低密度ディスプレイでの fontSize 自動拡大用)
* Babel plugin to add allowFontScaling={false} to all <Text> and <TextInput> JSX elements.
*
* React 19 で defaultProps が関数コンポーネントで非推奨になったため、
* Text.defaultProps.allowFontScaling = false の代替として使用する。
*/
module.exports = function ({ types: t }) {
const TARGET_COMPONENTS = new Set(["Text", "TextInput"]);
@@ -12,27 +12,20 @@ module.exports = function ({ types: t }) {
JSXOpeningElement(path) {
const nameNode = path.node.name;
let isTarget = false;
// <Text> or <TextInput>
// <Text> or <TextInput> (simple identifiers only)
if (
nameNode.type === "JSXIdentifier" &&
TARGET_COMPONENTS.has(nameNode.name)
) {
isTarget = true;
addAllowFontScaling(path, t);
}
// <Animated.Text>
// <Animated.Text> (member expression)
if (
nameNode.type === "JSXMemberExpression" &&
nameNode.property.name === "Text"
) {
isTarget = true;
}
if (isTarget) {
addAllowFontScaling(path, t);
wrapStyleForDensity(path, t);
}
},
},
@@ -55,56 +48,3 @@ function addAllowFontScaling(path, t) {
);
}
}
/**
* style プロップを global.__scaleTextStyle() でラップ:
* - style={EXPR} → style={global.__scaleTextStyle ? global.__scaleTextStyle(EXPR) : EXPR}
* - styleなし → style={global.__scaleTextStyle ? global.__scaleTextStyle() : undefined}
*/
function wrapStyleForDensity(path, t) {
const makeGlobalRef = () =>
t.memberExpression(t.identifier("global"), t.identifier("__scaleTextStyle"));
const styleAttrIndex = path.node.attributes.findIndex(
(attr) =>
attr.type === "JSXAttribute" &&
attr.name &&
attr.name.name === "style"
);
if (styleAttrIndex >= 0) {
const styleAttr = path.node.attributes[styleAttrIndex];
let originalExpr;
if (styleAttr.value && styleAttr.value.type === "JSXExpressionContainer") {
originalExpr = styleAttr.value.expression;
} else if (styleAttr.value) {
originalExpr = styleAttr.value;
} else {
return;
}
// style={global.__scaleTextStyle ? global.__scaleTextStyle(EXPR) : EXPR}
styleAttr.value = t.jsxExpressionContainer(
t.conditionalExpression(
makeGlobalRef(),
t.callExpression(makeGlobalRef(), [originalExpr]),
t.cloneNode(originalExpr, true)
)
);
} else {
// style属性なし → デフォルトfontSize(14)もスケーリング対象
path.node.attributes.push(
t.jsxAttribute(
t.jsxIdentifier("style"),
t.jsxExpressionContainer(
t.conditionalExpression(
makeGlobalRef(),
t.callExpression(makeGlobalRef(), []),
t.identifier("undefined")
)
)
)
);
}
}

View File

@@ -0,0 +1,82 @@
const { withDangerousMod } = require("@expo/config-plugins");
const fs = require("fs");
const path = require("path");
/**
* Disables the JR_shikoku_felica_balance widget on devices without NFC.
* Injects code into MainApplication.kt to check NFC availability at startup
* and disable the widget provider component if NFC is not supported.
*/
const withNfcWidgetGuard = (config) => {
return withDangerousMod(config, [
"android",
async (config) => {
const mainAppPath = path.join(
config.modRequest.projectRoot,
"android/app/src/main/java/jrshikokuinfo/xprocess/hrkn/MainApplication.kt"
);
if (!fs.existsSync(mainAppPath)) {
console.warn("[withNfcWidgetGuard] MainApplication.kt not found, skipping.");
return config;
}
let contents = fs.readFileSync(mainAppPath, "utf-8");
// Skip if already patched
if (contents.includes("disableFelicaWidgetIfNoNfc")) {
return config;
}
// Add necessary imports
contents = contents.replace(
"import android.app.Application",
"import android.app.Application\n" +
"import android.content.ComponentName\n" +
"import android.content.pm.PackageManager\n" +
"import android.nfc.NfcAdapter"
);
// Add the NFC check method and call it from onCreate
const nfcGuardMethod = `
/**
* Disable the Felica balance widget on devices without NFC hardware,
* so it won't appear in the Android widget picker.
*/
private fun disableFelicaWidgetIfNoNfc() {
if (NfcAdapter.getDefaultAdapter(this) == null) {
packageManager.setComponentEnabledSetting(
ComponentName(this, jrshikokuinfo.xprocess.hrkn.widget.JR_shikoku_felica_balance::class.java),
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP
)
} else {
packageManager.setComponentEnabledSetting(
ComponentName(this, jrshikokuinfo.xprocess.hrkn.widget.JR_shikoku_felica_balance::class.java),
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
PackageManager.DONT_KILL_APP
)
}
}`;
// Insert method before the closing brace of the class
contents = contents.replace(
/\n}\s*$/,
`\n${nfcGuardMethod}\n}\n`
);
// Insert call in onCreate after ApplicationLifecycleDispatcher
contents = contents.replace(
"ApplicationLifecycleDispatcher.onApplicationCreate(this)",
"ApplicationLifecycleDispatcher.onApplicationCreate(this)\n disableFelicaWidgetIfNoNfc()"
);
fs.writeFileSync(mainAppPath, contents);
console.log("[withNfcWidgetGuard] Patched MainApplication.kt to disable Felica widget on non-NFC devices.");
return config;
},
]);
};
module.exports = withNfcWidgetGuard;